From 23d0ddfb83fb2747b68eeac6c1f870f336294853 Mon Sep 17 00:00:00 2001 From: johha Date: Mon, 2 Mar 2026 13:43:52 +0100 Subject: [PATCH 01/20] test-opt: convert 8 message specs to lightweight_spec_helper Convert the following message specs to use lightweight_spec_helper instead of full spec_helper for faster test execution: - app_feature_update_message_spec.rb - app_show_message_spec.rb - buildpack_create_message_spec.rb - buildpack_update_message_spec.rb - domain_delete_shared_org_message_spec.rb - domain_show_message_spec.rb - domain_update_message_spec.rb - feature_flags_update_message_spec.rb These specs don't require database access, Config, Lifecycles, or the errors_on helper, making them suitable for lightweight testing. --- spec/unit/messages/app_feature_update_message_spec.rb | 2 +- spec/unit/messages/app_show_message_spec.rb | 3 ++- spec/unit/messages/buildpack_create_message_spec.rb | 2 +- spec/unit/messages/buildpack_update_message_spec.rb | 2 +- spec/unit/messages/domain_delete_shared_org_message_spec.rb | 2 +- spec/unit/messages/domain_show_message_spec.rb | 2 +- spec/unit/messages/domain_update_message_spec.rb | 2 +- spec/unit/messages/feature_flags_update_message_spec.rb | 2 +- 8 files changed, 9 insertions(+), 8 deletions(-) diff --git a/spec/unit/messages/app_feature_update_message_spec.rb b/spec/unit/messages/app_feature_update_message_spec.rb index 8c580b2aac4..19b9d1178d2 100644 --- a/spec/unit/messages/app_feature_update_message_spec.rb +++ b/spec/unit/messages/app_feature_update_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/app_feature_update_message' module VCAP::CloudController diff --git a/spec/unit/messages/app_show_message_spec.rb b/spec/unit/messages/app_show_message_spec.rb index 61c42b8ce0c..5728293005e 100644 --- a/spec/unit/messages/app_show_message_spec.rb +++ b/spec/unit/messages/app_show_message_spec.rb @@ -1,4 +1,5 @@ -require 'spec_helper' +require 'lightweight_spec_helper' +require 'messages/app_show_message' module VCAP::CloudController RSpec.describe AppShowMessage do diff --git a/spec/unit/messages/buildpack_create_message_spec.rb b/spec/unit/messages/buildpack_create_message_spec.rb index 36f766a63fa..c290aa55e4d 100644 --- a/spec/unit/messages/buildpack_create_message_spec.rb +++ b/spec/unit/messages/buildpack_create_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/buildpack_create_message' module VCAP::CloudController diff --git a/spec/unit/messages/buildpack_update_message_spec.rb b/spec/unit/messages/buildpack_update_message_spec.rb index c467f597e44..4effebc8d8f 100644 --- a/spec/unit/messages/buildpack_update_message_spec.rb +++ b/spec/unit/messages/buildpack_update_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/buildpack_update_message' module VCAP::CloudController diff --git a/spec/unit/messages/domain_delete_shared_org_message_spec.rb b/spec/unit/messages/domain_delete_shared_org_message_spec.rb index d1d140ea747..09bdb987737 100644 --- a/spec/unit/messages/domain_delete_shared_org_message_spec.rb +++ b/spec/unit/messages/domain_delete_shared_org_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/domain_delete_shared_org_message' module VCAP::CloudController diff --git a/spec/unit/messages/domain_show_message_spec.rb b/spec/unit/messages/domain_show_message_spec.rb index a77e73f83ff..fac785f6fa7 100644 --- a/spec/unit/messages/domain_show_message_spec.rb +++ b/spec/unit/messages/domain_show_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/domain_show_message' module VCAP::CloudController diff --git a/spec/unit/messages/domain_update_message_spec.rb b/spec/unit/messages/domain_update_message_spec.rb index 49fc4c6688e..f4b7458940d 100644 --- a/spec/unit/messages/domain_update_message_spec.rb +++ b/spec/unit/messages/domain_update_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/domain_update_message' module VCAP::CloudController diff --git a/spec/unit/messages/feature_flags_update_message_spec.rb b/spec/unit/messages/feature_flags_update_message_spec.rb index a60416e0128..caad6e59767 100644 --- a/spec/unit/messages/feature_flags_update_message_spec.rb +++ b/spec/unit/messages/feature_flags_update_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/feature_flags_update_message' module VCAP::CloudController From 4d60583a79784b88f2a75fc686fca071736496ad Mon Sep 17 00:00:00 2001 From: johha Date: Mon, 2 Mar 2026 13:47:10 +0100 Subject: [PATCH 02/20] test-opt: convert 8 more message specs to lightweight_spec_helper Convert the following message specs to use lightweight_spec_helper: - isolation_segment_relationship_org_message_spec.rb - manifest_buildpack_message_spec.rb - manifest_process_update_message_spec.rb - manifest_service_binding_create_message_spec.rb - organization_quota_apply_message_spec.rb - organization_quotas_create_message_spec.rb - organization_quotas_update_message_spec.rb - package_update_message_spec.rb Total converted: 16 message specs --- .../messages/isolation_segment_relationship_org_message_spec.rb | 2 +- spec/unit/messages/manifest_buildpack_message_spec.rb | 2 +- spec/unit/messages/manifest_process_update_message_spec.rb | 2 +- .../messages/manifest_service_binding_create_message_spec.rb | 2 +- spec/unit/messages/organization_quota_apply_message_spec.rb | 2 +- spec/unit/messages/organization_quotas_create_message_spec.rb | 2 +- spec/unit/messages/organization_quotas_update_message_spec.rb | 2 +- spec/unit/messages/package_update_message_spec.rb | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/spec/unit/messages/isolation_segment_relationship_org_message_spec.rb b/spec/unit/messages/isolation_segment_relationship_org_message_spec.rb index 56d22375275..336c4834dc3 100644 --- a/spec/unit/messages/isolation_segment_relationship_org_message_spec.rb +++ b/spec/unit/messages/isolation_segment_relationship_org_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/isolation_segment_relationship_org_message' module VCAP::CloudController diff --git a/spec/unit/messages/manifest_buildpack_message_spec.rb b/spec/unit/messages/manifest_buildpack_message_spec.rb index 5893616ee1f..7e8e42a59eb 100644 --- a/spec/unit/messages/manifest_buildpack_message_spec.rb +++ b/spec/unit/messages/manifest_buildpack_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/manifest_buildpack_message' module VCAP::CloudController diff --git a/spec/unit/messages/manifest_process_update_message_spec.rb b/spec/unit/messages/manifest_process_update_message_spec.rb index f2d06e2de86..3be19f802a8 100644 --- a/spec/unit/messages/manifest_process_update_message_spec.rb +++ b/spec/unit/messages/manifest_process_update_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/manifest_process_update_message' module VCAP::CloudController diff --git a/spec/unit/messages/manifest_service_binding_create_message_spec.rb b/spec/unit/messages/manifest_service_binding_create_message_spec.rb index 0f130549c46..8382b42364a 100644 --- a/spec/unit/messages/manifest_service_binding_create_message_spec.rb +++ b/spec/unit/messages/manifest_service_binding_create_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/manifest_service_binding_create_message' module VCAP::CloudController diff --git a/spec/unit/messages/organization_quota_apply_message_spec.rb b/spec/unit/messages/organization_quota_apply_message_spec.rb index 296233fafe8..87be95c907f 100644 --- a/spec/unit/messages/organization_quota_apply_message_spec.rb +++ b/spec/unit/messages/organization_quota_apply_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/organization_quota_apply_message' module VCAP::CloudController diff --git a/spec/unit/messages/organization_quotas_create_message_spec.rb b/spec/unit/messages/organization_quotas_create_message_spec.rb index a784c1f605b..1c42ce53ef3 100644 --- a/spec/unit/messages/organization_quotas_create_message_spec.rb +++ b/spec/unit/messages/organization_quotas_create_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/organization_quotas_create_message' module VCAP::CloudController diff --git a/spec/unit/messages/organization_quotas_update_message_spec.rb b/spec/unit/messages/organization_quotas_update_message_spec.rb index c6ebb798c27..24790e9520d 100644 --- a/spec/unit/messages/organization_quotas_update_message_spec.rb +++ b/spec/unit/messages/organization_quotas_update_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/organization_quotas_update_message' module VCAP::CloudController diff --git a/spec/unit/messages/package_update_message_spec.rb b/spec/unit/messages/package_update_message_spec.rb index b48536c2653..2bc4d7492cf 100644 --- a/spec/unit/messages/package_update_message_spec.rb +++ b/spec/unit/messages/package_update_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/package_update_message' module VCAP::CloudController From 7a7e5331351e6c4f091ed3d90261ed8e0b91a056 Mon Sep 17 00:00:00 2001 From: johha Date: Mon, 2 Mar 2026 13:57:04 +0100 Subject: [PATCH 03/20] test-opt: convert 12 more message specs to lightweight_spec_helper Convert the following message specs to use lightweight_spec_helper: - process_scale_message_spec.rb - process_show_message_spec.rb - process_update_message_spec.rb - purge_message_spec.rb - quotas_apps_message_spec.rb - quotas_routes_message_spec.rb - quotas_services_message_spec.rb - revisions_update_message_spec.rb - role_create_message_spec.rb - role_show_message_spec.rb - route_mappings_update_message_spec.rb - route_show_message_spec.rb Total converted: 28 message specs --- spec/unit/messages/process_scale_message_spec.rb | 2 +- spec/unit/messages/process_show_message_spec.rb | 3 ++- spec/unit/messages/process_update_message_spec.rb | 2 +- spec/unit/messages/purge_message_spec.rb | 2 +- spec/unit/messages/quotas_apps_message_spec.rb | 3 ++- spec/unit/messages/quotas_routes_message_spec.rb | 3 ++- spec/unit/messages/quotas_services_message_spec.rb | 3 ++- spec/unit/messages/revisions_update_message_spec.rb | 2 +- spec/unit/messages/role_create_message_spec.rb | 2 +- spec/unit/messages/role_show_message_spec.rb | 3 ++- spec/unit/messages/route_mappings_update_message_spec.rb | 2 +- spec/unit/messages/route_show_message_spec.rb | 2 +- 12 files changed, 17 insertions(+), 12 deletions(-) diff --git a/spec/unit/messages/process_scale_message_spec.rb b/spec/unit/messages/process_scale_message_spec.rb index 0a45eeda210..1fba19b83a7 100644 --- a/spec/unit/messages/process_scale_message_spec.rb +++ b/spec/unit/messages/process_scale_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/process_scale_message' require 'messages/base_message' diff --git a/spec/unit/messages/process_show_message_spec.rb b/spec/unit/messages/process_show_message_spec.rb index be040dbfb7c..bde0a793d00 100644 --- a/spec/unit/messages/process_show_message_spec.rb +++ b/spec/unit/messages/process_show_message_spec.rb @@ -1,4 +1,5 @@ -require 'spec_helper' +require 'lightweight_spec_helper' +require 'messages/process_show_message' module VCAP::CloudController RSpec.describe ProcessShowMessage do diff --git a/spec/unit/messages/process_update_message_spec.rb b/spec/unit/messages/process_update_message_spec.rb index 771aaf84ff6..675d77f822f 100644 --- a/spec/unit/messages/process_update_message_spec.rb +++ b/spec/unit/messages/process_update_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/process_update_message' require 'messages/metadata_base_message' diff --git a/spec/unit/messages/purge_message_spec.rb b/spec/unit/messages/purge_message_spec.rb index e1133671957..32ba2336d91 100644 --- a/spec/unit/messages/purge_message_spec.rb +++ b/spec/unit/messages/purge_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/purge_message' module VCAP::CloudController diff --git a/spec/unit/messages/quotas_apps_message_spec.rb b/spec/unit/messages/quotas_apps_message_spec.rb index fd3b69390f1..ac04f20529f 100644 --- a/spec/unit/messages/quotas_apps_message_spec.rb +++ b/spec/unit/messages/quotas_apps_message_spec.rb @@ -1,4 +1,5 @@ -require 'spec_helper' +require 'lightweight_spec_helper' +require 'messages/quotas_apps_message' module VCAP::CloudController RSpec.describe QuotasAppsMessage do diff --git a/spec/unit/messages/quotas_routes_message_spec.rb b/spec/unit/messages/quotas_routes_message_spec.rb index 9007f7c7752..31ebd163af7 100644 --- a/spec/unit/messages/quotas_routes_message_spec.rb +++ b/spec/unit/messages/quotas_routes_message_spec.rb @@ -1,4 +1,5 @@ -require 'spec_helper' +require 'lightweight_spec_helper' +require 'messages/quotas_routes_message' module VCAP::CloudController RSpec.describe QuotasRoutesMessage do diff --git a/spec/unit/messages/quotas_services_message_spec.rb b/spec/unit/messages/quotas_services_message_spec.rb index 0e1d3bfd8a8..5098f97e3e8 100644 --- a/spec/unit/messages/quotas_services_message_spec.rb +++ b/spec/unit/messages/quotas_services_message_spec.rb @@ -1,4 +1,5 @@ -require 'spec_helper' +require 'lightweight_spec_helper' +require 'messages/quotas_services_message' module VCAP::CloudController RSpec.describe QuotasServicesMessage do diff --git a/spec/unit/messages/revisions_update_message_spec.rb b/spec/unit/messages/revisions_update_message_spec.rb index bd40a1e8c8e..cafbb9686cd 100644 --- a/spec/unit/messages/revisions_update_message_spec.rb +++ b/spec/unit/messages/revisions_update_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/revisions_update_message' module VCAP::CloudController diff --git a/spec/unit/messages/role_create_message_spec.rb b/spec/unit/messages/role_create_message_spec.rb index 6c900c239d2..c434efb911f 100644 --- a/spec/unit/messages/role_create_message_spec.rb +++ b/spec/unit/messages/role_create_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/role_create_message' require 'models/helpers/role_types' diff --git a/spec/unit/messages/role_show_message_spec.rb b/spec/unit/messages/role_show_message_spec.rb index 4494af49dba..024bd7d98c5 100644 --- a/spec/unit/messages/role_show_message_spec.rb +++ b/spec/unit/messages/role_show_message_spec.rb @@ -1,4 +1,5 @@ -require 'spec_helper' +require 'lightweight_spec_helper' +require 'messages/role_show_message' module VCAP::CloudController RSpec.describe RoleShowMessage do diff --git a/spec/unit/messages/route_mappings_update_message_spec.rb b/spec/unit/messages/route_mappings_update_message_spec.rb index 3308ee84046..a5cb2bf58f1 100644 --- a/spec/unit/messages/route_mappings_update_message_spec.rb +++ b/spec/unit/messages/route_mappings_update_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/route_mappings_update_message' module VCAP::CloudController diff --git a/spec/unit/messages/route_show_message_spec.rb b/spec/unit/messages/route_show_message_spec.rb index 3c7fa8378af..e01165d14a6 100644 --- a/spec/unit/messages/route_show_message_spec.rb +++ b/spec/unit/messages/route_show_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/route_show_message' module VCAP::CloudController From 2af1bcc7e67300e96c879625b5f3dd2c1717e0ca Mon Sep 17 00:00:00 2001 From: johha Date: Mon, 2 Mar 2026 14:00:13 +0100 Subject: [PATCH 04/20] test-opt: convert 10 more message specs to lightweight_spec_helper Convert the following message specs to use lightweight_spec_helper: - service_credential_binding_create_message_spec.rb - sidecar_create_message_spec.rb - sidecar_update_message_spec.rb - space_delete_unmapped_routes_message_spec.rb - space_feature_update_message_spec.rb - stack_create_message_spec.rb - to_many_relationship_message_spec.rb - user_create_message_spec.rb - user_update_message_spec.rb - v2_v3_resource_translator_spec.rb Total converted: 38 message specs --- .../messages/service_credential_binding_create_message_spec.rb | 2 +- spec/unit/messages/sidecar_create_message_spec.rb | 2 +- spec/unit/messages/sidecar_update_message_spec.rb | 2 +- spec/unit/messages/space_delete_unmapped_routes_message_spec.rb | 2 +- spec/unit/messages/space_feature_update_message_spec.rb | 2 +- spec/unit/messages/stack_create_message_spec.rb | 2 +- spec/unit/messages/to_many_relationship_message_spec.rb | 2 +- spec/unit/messages/user_create_message_spec.rb | 2 +- spec/unit/messages/user_update_message_spec.rb | 2 +- spec/unit/messages/v2_v3_resource_translator_spec.rb | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/spec/unit/messages/service_credential_binding_create_message_spec.rb b/spec/unit/messages/service_credential_binding_create_message_spec.rb index 15a6db4afbc..f6a407f2129 100644 --- a/spec/unit/messages/service_credential_binding_create_message_spec.rb +++ b/spec/unit/messages/service_credential_binding_create_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/service_credential_binding_create_message' module VCAP::CloudController diff --git a/spec/unit/messages/sidecar_create_message_spec.rb b/spec/unit/messages/sidecar_create_message_spec.rb index 56305a64a33..0e29e1b9a38 100644 --- a/spec/unit/messages/sidecar_create_message_spec.rb +++ b/spec/unit/messages/sidecar_create_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/sidecar_create_message' module VCAP::CloudController diff --git a/spec/unit/messages/sidecar_update_message_spec.rb b/spec/unit/messages/sidecar_update_message_spec.rb index 62120d914b7..0ad8cce12ee 100644 --- a/spec/unit/messages/sidecar_update_message_spec.rb +++ b/spec/unit/messages/sidecar_update_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/sidecar_update_message' module VCAP::CloudController diff --git a/spec/unit/messages/space_delete_unmapped_routes_message_spec.rb b/spec/unit/messages/space_delete_unmapped_routes_message_spec.rb index cad0ac739e3..913df7e71dc 100644 --- a/spec/unit/messages/space_delete_unmapped_routes_message_spec.rb +++ b/spec/unit/messages/space_delete_unmapped_routes_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/space_delete_unmapped_routes_message' module VCAP::CloudController diff --git a/spec/unit/messages/space_feature_update_message_spec.rb b/spec/unit/messages/space_feature_update_message_spec.rb index 33df6a702ed..a6684b6b612 100644 --- a/spec/unit/messages/space_feature_update_message_spec.rb +++ b/spec/unit/messages/space_feature_update_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/space_feature_update_message' module VCAP::CloudController diff --git a/spec/unit/messages/stack_create_message_spec.rb b/spec/unit/messages/stack_create_message_spec.rb index aadf7b9191b..788f99c0a1c 100644 --- a/spec/unit/messages/stack_create_message_spec.rb +++ b/spec/unit/messages/stack_create_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/stack_create_message' RSpec.describe VCAP::CloudController::StackCreateMessage do diff --git a/spec/unit/messages/to_many_relationship_message_spec.rb b/spec/unit/messages/to_many_relationship_message_spec.rb index 072cb6f8f05..4ff36a04982 100644 --- a/spec/unit/messages/to_many_relationship_message_spec.rb +++ b/spec/unit/messages/to_many_relationship_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/to_many_relationship_message' module VCAP::CloudController diff --git a/spec/unit/messages/user_create_message_spec.rb b/spec/unit/messages/user_create_message_spec.rb index 40427497f9b..4cf5d80c510 100644 --- a/spec/unit/messages/user_create_message_spec.rb +++ b/spec/unit/messages/user_create_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/user_create_message' module VCAP::CloudController diff --git a/spec/unit/messages/user_update_message_spec.rb b/spec/unit/messages/user_update_message_spec.rb index 0946a36fc79..e01dc908504 100644 --- a/spec/unit/messages/user_update_message_spec.rb +++ b/spec/unit/messages/user_update_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/user_update_message' module VCAP::CloudController diff --git a/spec/unit/messages/v2_v3_resource_translator_spec.rb b/spec/unit/messages/v2_v3_resource_translator_spec.rb index 3df40391155..22a7dc87f19 100644 --- a/spec/unit/messages/v2_v3_resource_translator_spec.rb +++ b/spec/unit/messages/v2_v3_resource_translator_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/v2_v3_resource_translator' RSpec.describe VCAP::CloudController::V2V3ResourceTranslator do From 4fa15d0c2faef8295dc0c724a3041b424cfa2494 Mon Sep 17 00:00:00 2001 From: johha Date: Mon, 2 Mar 2026 14:30:24 +0100 Subject: [PATCH 05/20] Fix db_spec_helper loading issues - Add require for cloud_controller/diego/constants in instances_reporter.rb to ensure LRP_RUNNING constant is defined before use - Add require for 'oj' in db_spec_helper to ensure Oj is available before initializers run This fixes the broken db_spec_helper which was failing with: NameError: uninitialized constant VCAP::CloudController::Diego::LRP_RUNNING --- lib/cloud_controller/diego/reporters/instances_reporter.rb | 1 + spec/db_spec_helper.rb | 1 + 2 files changed, 2 insertions(+) diff --git a/lib/cloud_controller/diego/reporters/instances_reporter.rb b/lib/cloud_controller/diego/reporters/instances_reporter.rb index 7874e720d1c..4bb39865a3a 100644 --- a/lib/cloud_controller/diego/reporters/instances_reporter.rb +++ b/lib/cloud_controller/diego/reporters/instances_reporter.rb @@ -1,5 +1,6 @@ require 'utils/workpool' require 'cloud_controller/diego/reporters/reporter_mixins' +require 'cloud_controller/diego/constants' require 'diego/lrp_constants' module VCAP::CloudController diff --git a/spec/db_spec_helper.rb b/spec/db_spec_helper.rb index 426fa2a8c89..624535ad538 100644 --- a/spec/db_spec_helper.rb +++ b/spec/db_spec_helper.rb @@ -4,6 +4,7 @@ require 'rspec/collection_matchers' require 'rails' + require 'oj' require 'support/bootstrap/spec_bootstrap' require 'support/database_isolation' require 'sequel_plugins/sequel_plugins' From 3061be8260c13271c9787d9a9819f981f17c06cd Mon Sep 17 00:00:00 2001 From: johha Date: Mon, 2 Mar 2026 14:38:06 +0100 Subject: [PATCH 06/20] Convert 4 fetcher specs to use db_spec_helper Add Sequel timezone configuration to db_spec_helper to fix timestamp comparison issues in tests. Converted specs: - app_fetcher_spec.rb - assign_current_droplet_fetcher_spec.rb - base_list_fetcher_spec.rb - build_list_fetcher_spec.rb These specs only need database models, not the full controller stack, so they can use the lighter db_spec_helper for faster load times. --- spec/db_spec_helper.rb | 3 +++ spec/unit/fetchers/app_fetcher_spec.rb | 2 +- spec/unit/fetchers/assign_current_droplet_fetcher_spec.rb | 2 +- spec/unit/fetchers/base_list_fetcher_spec.rb | 2 +- spec/unit/fetchers/build_list_fetcher_spec.rb | 2 +- 5 files changed, 7 insertions(+), 4 deletions(-) diff --git a/spec/db_spec_helper.rb b/spec/db_spec_helper.rb index 624535ad538..ec5ed14d61b 100644 --- a/spec/db_spec_helper.rb +++ b/spec/db_spec_helper.rb @@ -5,6 +5,9 @@ require 'rails' require 'oj' + require 'sequel' + Sequel.default_timezone = :utc + require 'support/bootstrap/spec_bootstrap' require 'support/database_isolation' require 'sequel_plugins/sequel_plugins' diff --git a/spec/unit/fetchers/app_fetcher_spec.rb b/spec/unit/fetchers/app_fetcher_spec.rb index 1f7ca122e18..fde7568969b 100644 --- a/spec/unit/fetchers/app_fetcher_spec.rb +++ b/spec/unit/fetchers/app_fetcher_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'db_spec_helper' require 'fetchers/app_fetcher' module VCAP::CloudController diff --git a/spec/unit/fetchers/assign_current_droplet_fetcher_spec.rb b/spec/unit/fetchers/assign_current_droplet_fetcher_spec.rb index 692d1514e03..a8fc16cbced 100644 --- a/spec/unit/fetchers/assign_current_droplet_fetcher_spec.rb +++ b/spec/unit/fetchers/assign_current_droplet_fetcher_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'db_spec_helper' require 'fetchers/assign_current_droplet_fetcher' module VCAP::CloudController diff --git a/spec/unit/fetchers/base_list_fetcher_spec.rb b/spec/unit/fetchers/base_list_fetcher_spec.rb index ef5a44dd6e4..982416eeb81 100644 --- a/spec/unit/fetchers/base_list_fetcher_spec.rb +++ b/spec/unit/fetchers/base_list_fetcher_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'db_spec_helper' require 'messages/events_list_message' require 'fetchers/event_list_fetcher' diff --git a/spec/unit/fetchers/build_list_fetcher_spec.rb b/spec/unit/fetchers/build_list_fetcher_spec.rb index 8561b222d1e..641abb3d1f4 100644 --- a/spec/unit/fetchers/build_list_fetcher_spec.rb +++ b/spec/unit/fetchers/build_list_fetcher_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'db_spec_helper' require 'messages/builds_list_message' require 'fetchers/build_list_fetcher' From f9da616f0252eb15db38c4f8d49fcc3e379f3c27 Mon Sep 17 00:00:00 2001 From: johha Date: Mon, 2 Mar 2026 14:43:02 +0100 Subject: [PATCH 07/20] Convert 9 more fetcher specs to use db_spec_helper Converted specs: - droplet_fetcher_spec.rb - event_list_fetcher_spec.rb - organization_quota_list_fetcher_spec.rb - organization_user_roles_fetcher_spec.rb - package_fetcher_spec.rb - process_fetcher_spec.rb - route_destinations_list_fetcher_spec.rb - service_binding_list_fetcher_spec.rb - space_quota_list_fetcher_spec.rb These specs only need database models, not the full controller stack, reducing test load time from ~7s to ~2s per file. --- spec/unit/fetchers/droplet_fetcher_spec.rb | 2 +- spec/unit/fetchers/event_list_fetcher_spec.rb | 2 +- spec/unit/fetchers/organization_quota_list_fetcher_spec.rb | 2 +- spec/unit/fetchers/organization_user_roles_fetcher_spec.rb | 2 +- spec/unit/fetchers/package_fetcher_spec.rb | 2 +- spec/unit/fetchers/process_fetcher_spec.rb | 2 +- spec/unit/fetchers/route_destinations_list_fetcher_spec.rb | 2 +- spec/unit/fetchers/service_binding_list_fetcher_spec.rb | 2 +- spec/unit/fetchers/space_quota_list_fetcher_spec.rb | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/spec/unit/fetchers/droplet_fetcher_spec.rb b/spec/unit/fetchers/droplet_fetcher_spec.rb index a53478e3ee3..7f0c419720c 100644 --- a/spec/unit/fetchers/droplet_fetcher_spec.rb +++ b/spec/unit/fetchers/droplet_fetcher_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'db_spec_helper' require 'fetchers/droplet_fetcher' module VCAP::CloudController diff --git a/spec/unit/fetchers/event_list_fetcher_spec.rb b/spec/unit/fetchers/event_list_fetcher_spec.rb index c599ae4c1ff..08e41c2ec5d 100644 --- a/spec/unit/fetchers/event_list_fetcher_spec.rb +++ b/spec/unit/fetchers/event_list_fetcher_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'db_spec_helper' require 'messages/events_list_message' require 'fetchers/event_list_fetcher' diff --git a/spec/unit/fetchers/organization_quota_list_fetcher_spec.rb b/spec/unit/fetchers/organization_quota_list_fetcher_spec.rb index 903b9b35ab2..42283f07c29 100644 --- a/spec/unit/fetchers/organization_quota_list_fetcher_spec.rb +++ b/spec/unit/fetchers/organization_quota_list_fetcher_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'db_spec_helper' require 'fetchers/organization_quota_list_fetcher' require 'messages/organization_quotas_list_message' diff --git a/spec/unit/fetchers/organization_user_roles_fetcher_spec.rb b/spec/unit/fetchers/organization_user_roles_fetcher_spec.rb index 246bf770d10..6291c13c455 100644 --- a/spec/unit/fetchers/organization_user_roles_fetcher_spec.rb +++ b/spec/unit/fetchers/organization_user_roles_fetcher_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'db_spec_helper' require 'fetchers/organization_user_roles_fetcher' module VCAP::CloudController diff --git a/spec/unit/fetchers/package_fetcher_spec.rb b/spec/unit/fetchers/package_fetcher_spec.rb index 7ce1dcdc5dc..a2306745601 100644 --- a/spec/unit/fetchers/package_fetcher_spec.rb +++ b/spec/unit/fetchers/package_fetcher_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'db_spec_helper' require 'fetchers/package_fetcher' module VCAP::CloudController diff --git a/spec/unit/fetchers/process_fetcher_spec.rb b/spec/unit/fetchers/process_fetcher_spec.rb index a56751bb232..8eb0f43435a 100644 --- a/spec/unit/fetchers/process_fetcher_spec.rb +++ b/spec/unit/fetchers/process_fetcher_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'db_spec_helper' require 'fetchers/process_fetcher' module VCAP::CloudController diff --git a/spec/unit/fetchers/route_destinations_list_fetcher_spec.rb b/spec/unit/fetchers/route_destinations_list_fetcher_spec.rb index 986f791d669..63f3658024d 100644 --- a/spec/unit/fetchers/route_destinations_list_fetcher_spec.rb +++ b/spec/unit/fetchers/route_destinations_list_fetcher_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'db_spec_helper' require 'fetchers/route_destinations_list_fetcher' require 'messages/route_destinations_list_message' diff --git a/spec/unit/fetchers/service_binding_list_fetcher_spec.rb b/spec/unit/fetchers/service_binding_list_fetcher_spec.rb index 228e1c2e859..4a16b1e2412 100644 --- a/spec/unit/fetchers/service_binding_list_fetcher_spec.rb +++ b/spec/unit/fetchers/service_binding_list_fetcher_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'db_spec_helper' require 'fetchers/service_binding_list_fetcher' module VCAP::CloudController diff --git a/spec/unit/fetchers/space_quota_list_fetcher_spec.rb b/spec/unit/fetchers/space_quota_list_fetcher_spec.rb index 37bc07277e9..7485274d622 100644 --- a/spec/unit/fetchers/space_quota_list_fetcher_spec.rb +++ b/spec/unit/fetchers/space_quota_list_fetcher_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'db_spec_helper' require 'fetchers/space_quota_list_fetcher' require 'messages/space_quotas_list_message' From 47b56eb6179bdf775fc88fdf07b23c2c68f18490 Mon Sep 17 00:00:00 2001 From: johha Date: Mon, 2 Mar 2026 14:56:49 +0100 Subject: [PATCH 08/20] Convert 11 presenter specs to use db_spec_helper Converted specs: - app_env_presenter_spec.rb - cache_key_presenter_spec.rb - domain_shared_orgs_presenter_spec.rb - organization_quota_presenter_spec.rb - relationship_presenter_spec.rb - route_destination_presenter_spec.rb - route_destinations_presenter_spec.rb - service_offering_presenter_spec.rb - space_quota_presenter_spec.rb - space_usage_summary_presenter_spec.rb - to_many_relationship_presenter_spec.rb These specs only need database models, not the full controller stack. --- spec/unit/presenters/v3/app_env_presenter_spec.rb | 2 +- spec/unit/presenters/v3/cache_key_presenter_spec.rb | 2 +- spec/unit/presenters/v3/domain_shared_orgs_presenter_spec.rb | 2 +- spec/unit/presenters/v3/organization_quota_presenter_spec.rb | 2 +- spec/unit/presenters/v3/relationship_presenter_spec.rb | 2 +- spec/unit/presenters/v3/route_destination_presenter_spec.rb | 2 +- spec/unit/presenters/v3/route_destinations_presenter_spec.rb | 2 +- spec/unit/presenters/v3/service_offering_presenter_spec.rb | 2 +- spec/unit/presenters/v3/space_quota_presenter_spec.rb | 2 +- spec/unit/presenters/v3/space_usage_summary_presenter_spec.rb | 2 +- spec/unit/presenters/v3/to_many_relationship_presenter_spec.rb | 2 +- 11 files changed, 11 insertions(+), 11 deletions(-) diff --git a/spec/unit/presenters/v3/app_env_presenter_spec.rb b/spec/unit/presenters/v3/app_env_presenter_spec.rb index 21cd0ebcdb6..0c02f18986d 100644 --- a/spec/unit/presenters/v3/app_env_presenter_spec.rb +++ b/spec/unit/presenters/v3/app_env_presenter_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'db_spec_helper' require 'presenters/v3/app_env_presenter' module VCAP::CloudController::Presenters::V3 diff --git a/spec/unit/presenters/v3/cache_key_presenter_spec.rb b/spec/unit/presenters/v3/cache_key_presenter_spec.rb index 1005b495554..8615fce76a9 100644 --- a/spec/unit/presenters/v3/cache_key_presenter_spec.rb +++ b/spec/unit/presenters/v3/cache_key_presenter_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'db_spec_helper' require 'presenters/v3/cache_key_presenter' module VCAP::CloudController::Presenters::V3 diff --git a/spec/unit/presenters/v3/domain_shared_orgs_presenter_spec.rb b/spec/unit/presenters/v3/domain_shared_orgs_presenter_spec.rb index 701c16f182b..d118adbfafc 100644 --- a/spec/unit/presenters/v3/domain_shared_orgs_presenter_spec.rb +++ b/spec/unit/presenters/v3/domain_shared_orgs_presenter_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'db_spec_helper' require 'presenters/v3/domain_shared_orgs_presenter' module VCAP::CloudController::Presenters::V3 diff --git a/spec/unit/presenters/v3/organization_quota_presenter_spec.rb b/spec/unit/presenters/v3/organization_quota_presenter_spec.rb index 080fd4c5a83..c5fdad73f6e 100644 --- a/spec/unit/presenters/v3/organization_quota_presenter_spec.rb +++ b/spec/unit/presenters/v3/organization_quota_presenter_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'db_spec_helper' require 'presenters/v3/organization_quota_presenter' module VCAP::CloudController::Presenters::V3 diff --git a/spec/unit/presenters/v3/relationship_presenter_spec.rb b/spec/unit/presenters/v3/relationship_presenter_spec.rb index f4d0e64dc06..cb984c0a50d 100644 --- a/spec/unit/presenters/v3/relationship_presenter_spec.rb +++ b/spec/unit/presenters/v3/relationship_presenter_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'db_spec_helper' require 'presenters/v3/relationship_presenter' module VCAP::CloudController::Presenters::V3 diff --git a/spec/unit/presenters/v3/route_destination_presenter_spec.rb b/spec/unit/presenters/v3/route_destination_presenter_spec.rb index 6484403fd4b..ffc8f8089bf 100644 --- a/spec/unit/presenters/v3/route_destination_presenter_spec.rb +++ b/spec/unit/presenters/v3/route_destination_presenter_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'db_spec_helper' require 'presenters/v3/route_destination_presenter' module VCAP::CloudController::Presenters::V3 diff --git a/spec/unit/presenters/v3/route_destinations_presenter_spec.rb b/spec/unit/presenters/v3/route_destinations_presenter_spec.rb index fea18da3192..01b418e136a 100644 --- a/spec/unit/presenters/v3/route_destinations_presenter_spec.rb +++ b/spec/unit/presenters/v3/route_destinations_presenter_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'db_spec_helper' require 'presenters/v3/route_destination_presenter' require 'messages/route_destinations_list_message' diff --git a/spec/unit/presenters/v3/service_offering_presenter_spec.rb b/spec/unit/presenters/v3/service_offering_presenter_spec.rb index c99864c3223..721ad5c03f6 100644 --- a/spec/unit/presenters/v3/service_offering_presenter_spec.rb +++ b/spec/unit/presenters/v3/service_offering_presenter_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'db_spec_helper' require 'support/link_helpers' require 'presenters/v3/service_offering_presenter' diff --git a/spec/unit/presenters/v3/space_quota_presenter_spec.rb b/spec/unit/presenters/v3/space_quota_presenter_spec.rb index 93e07b7a858..8123157c9d2 100644 --- a/spec/unit/presenters/v3/space_quota_presenter_spec.rb +++ b/spec/unit/presenters/v3/space_quota_presenter_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'db_spec_helper' require 'presenters/v3/space_quota_presenter' module VCAP::CloudController::Presenters::V3 diff --git a/spec/unit/presenters/v3/space_usage_summary_presenter_spec.rb b/spec/unit/presenters/v3/space_usage_summary_presenter_spec.rb index 2a35d82e1a9..7b8a9b9597e 100644 --- a/spec/unit/presenters/v3/space_usage_summary_presenter_spec.rb +++ b/spec/unit/presenters/v3/space_usage_summary_presenter_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'db_spec_helper' require 'presenters/v3/space_usage_summary_presenter' module VCAP::CloudController::Presenters::V3 diff --git a/spec/unit/presenters/v3/to_many_relationship_presenter_spec.rb b/spec/unit/presenters/v3/to_many_relationship_presenter_spec.rb index 5a3c0172027..5b475e2b163 100644 --- a/spec/unit/presenters/v3/to_many_relationship_presenter_spec.rb +++ b/spec/unit/presenters/v3/to_many_relationship_presenter_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'db_spec_helper' require 'presenters/v3/to_many_relationship_presenter' module VCAP::CloudController::Presenters::V3 From 2f69bf22f8a0229b757e7a536373d32debe9c54c Mon Sep 17 00:00:00 2001 From: johha Date: Mon, 2 Mar 2026 15:04:35 +0100 Subject: [PATCH 09/20] Convert 4 decorator/repository specs to use db_spec_helper Converted specs: - embed_process_instances_decorator_spec.rb - field_service_offering_service_broker_decorator_spec.rb - field_service_plan_service_broker_decorator_spec.rb - event_types_spec.rb Added explicit requires for decorator classes since db_spec_helper doesn't autoload all application classes. --- spec/unit/decorators/embed_process_instances_decorator_spec.rb | 3 ++- .../field_service_offering_service_broker_decorator_spec.rb | 2 +- .../field_service_plan_service_broker_decorator_spec.rb | 2 +- spec/unit/repositories/event_types_spec.rb | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/spec/unit/decorators/embed_process_instances_decorator_spec.rb b/spec/unit/decorators/embed_process_instances_decorator_spec.rb index 3bd52c72562..d6ed356ae86 100644 --- a/spec/unit/decorators/embed_process_instances_decorator_spec.rb +++ b/spec/unit/decorators/embed_process_instances_decorator_spec.rb @@ -1,4 +1,5 @@ -require 'spec_helper' +require 'db_spec_helper' +require 'decorators/embed_process_instances_decorator' module VCAP::CloudController RSpec.describe EmbedProcessInstancesDecorator do diff --git a/spec/unit/decorators/field_service_offering_service_broker_decorator_spec.rb b/spec/unit/decorators/field_service_offering_service_broker_decorator_spec.rb index ce4097c64d7..92fc28ea62e 100644 --- a/spec/unit/decorators/field_service_offering_service_broker_decorator_spec.rb +++ b/spec/unit/decorators/field_service_offering_service_broker_decorator_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'db_spec_helper' require 'decorators/field_service_offering_service_broker_decorator' require 'field_decorator_spec_shared_examples' diff --git a/spec/unit/decorators/field_service_plan_service_broker_decorator_spec.rb b/spec/unit/decorators/field_service_plan_service_broker_decorator_spec.rb index 79e1ea060b8..bd97deb66fc 100644 --- a/spec/unit/decorators/field_service_plan_service_broker_decorator_spec.rb +++ b/spec/unit/decorators/field_service_plan_service_broker_decorator_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'db_spec_helper' require 'decorators/field_service_plan_service_broker_decorator' require 'field_decorator_spec_shared_examples' diff --git a/spec/unit/repositories/event_types_spec.rb b/spec/unit/repositories/event_types_spec.rb index 1dcf6997c16..19d6fecb118 100644 --- a/spec/unit/repositories/event_types_spec.rb +++ b/spec/unit/repositories/event_types_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'db_spec_helper' require 'repositories/event_types' module VCAP::CloudController From 870056e0af76c444d2583691fd7c228e7ede3666 Mon Sep 17 00:00:00 2001 From: johha Date: Mon, 2 Mar 2026 15:41:51 +0100 Subject: [PATCH 10/20] Split apps_spec.rb into 8 smaller files for better parallelization Original file: spec/request/apps_spec.rb (3,542 lines, 402 examples) Split into: - create_spec.rb (POST /v3/apps) - 451 lines - list_spec.rb (GET /v3/apps) - 939 lines - show_spec.rb (GET /v3/apps/:guid) - 494 lines - builds_and_ssh_spec.rb - 221 lines - delete_and_update_spec.rb - 328 lines - actions_spec.rb (start, stop, restart) - 663 lines - droplet_spec.rb (current_droplet endpoints) - 328 lines - environment_spec.rb (environment_variables, permissions) - 177 lines Shared test setup extracted to shared_context.rb This split enables better parallel test distribution since each file can run on a separate worker. --- spec/request/apps/actions_spec.rb | 664 ++++++++++++++ spec/request/apps/builds_and_ssh_spec.rb | 222 +++++ spec/request/apps/create_spec.rb | 451 ++++++++++ spec/request/apps/delete_and_update_spec.rb | 329 +++++++ spec/request/apps/droplet_spec.rb | 329 +++++++ spec/request/apps/environment_spec.rb | 178 ++++ spec/request/apps/list_spec.rb | 940 ++++++++++++++++++++ spec/request/apps/shared_context.rb | 10 + spec/request/apps/show_spec.rb | 495 +++++++++++ 9 files changed, 3618 insertions(+) create mode 100644 spec/request/apps/actions_spec.rb create mode 100644 spec/request/apps/builds_and_ssh_spec.rb create mode 100644 spec/request/apps/create_spec.rb create mode 100644 spec/request/apps/delete_and_update_spec.rb create mode 100644 spec/request/apps/droplet_spec.rb create mode 100644 spec/request/apps/environment_spec.rb create mode 100644 spec/request/apps/list_spec.rb create mode 100644 spec/request/apps/shared_context.rb create mode 100644 spec/request/apps/show_spec.rb diff --git a/spec/request/apps/actions_spec.rb b/spec/request/apps/actions_spec.rb new file mode 100644 index 00000000000..344dbb5f587 --- /dev/null +++ b/spec/request/apps/actions_spec.rb @@ -0,0 +1,664 @@ +require 'spec_helper' +require 'actions/process_create_from_app_droplet' +require 'request_spec_shared_examples' +require_relative 'shared_context' + +# Split from spec/request/apps_spec.rb for better test parallelization + +RSpec.describe 'Apps' do + include_context 'apps request spec' + + describe 'POST /v3/apps/:guid/actions/start' do + let(:stack) { VCAP::CloudController::Stack.make(name: 'stack-name') } + let(:app_model) do + VCAP::CloudController::AppModel.make( + :buildpack, + name: 'app-name', + space: space, + desired_state: 'STOPPED' + ) + end + + context 'app lifecycle is buildpack' do + let!(:droplet) do + VCAP::CloudController::DropletModel.make( + :buildpack, + app: app_model, + state: VCAP::CloudController::DropletModel::STAGED_STATE + ) + end + + before do + app_model.lifecycle_data.buildpacks = ['http://example.com/git'] + app_model.lifecycle_data.stack = stack.name + app_model.lifecycle_data.save + app_model.droplet = droplet + app_model.save + end + + context 'starting an app' do + let(:api_call) { ->(user_headers) { post "/v3/apps/#{app_model.guid}/actions/start", nil, user_headers } } + let(:app_start_response_object) do + { + 'name' => 'app-name', + 'guid' => app_model.guid, + 'state' => 'STARTED', + 'created_at' => iso8601, + 'updated_at' => iso8601, + 'metadata' => { 'labels' => {}, 'annotations' => {} }, + 'lifecycle' => { + 'type' => 'buildpack', + 'data' => { + 'buildpacks' => ['http://example.com/git'], + 'stack' => 'stack-name' + } + }, + 'relationships' => { + 'space' => { + 'data' => { + 'guid' => space.guid + } + }, + 'current_droplet' => { + 'data' => { + 'guid' => droplet.guid + } + } + }, + 'links' => { + 'self' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}" }, + 'processes' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/processes" }, + 'packages' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/packages" }, + 'environment_variables' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/environment_variables" }, + 'space' => { 'href' => "#{link_prefix}/v3/spaces/#{space.guid}" }, + 'current_droplet' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/droplets/current" }, + 'droplets' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/droplets" }, + 'tasks' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/tasks" }, + 'start' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/start", 'method' => 'POST' }, + 'stop' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/stop", 'method' => 'POST' }, + 'clear_buildpack_cache' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/clear_buildpack_cache", 'method' => 'POST' }, + 'revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/revisions" }, + 'deployed_revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/revisions/deployed" }, + 'features' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/features" } + } + } + end + + let(:expected_codes_and_responses) do + h = Hash.new({ code: 403, errors: CF_NOT_AUTHORIZED }.freeze) + h['no_role'] = { code: 404 } + h['org_auditor'] = { code: 404 } + h['org_billing_manager'] = { code: 404 } + h['admin'] = { + code: 200, + response_object: app_start_response_object + } + h['space_supporter'] = { + code: 200, + response_object: app_start_response_object + } + h['space_developer'] = { + code: 200, + response_object: app_start_response_object + } + h + end + + before do + space.organization.add_user(user) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + + context 'when organization is suspended' do + let(:expected_codes_and_responses) do + h = super() + %w[space_supporter space_developer].each { |r| h[r] = { code: 403, errors: CF_ORG_SUSPENDED } } + h + end + + before do + org.update(status: VCAP::CloudController::Organization::SUSPENDED) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + + describe 'limiting the application log rates' do + let(:log_rate_limit) { -1 } + let(:space_log_rate_limit) { -1 } + let(:org_log_rate_limit) { -1 } + let(:org_quota_definition) { VCAP::CloudController::QuotaDefinition.make(log_rate_limit: org_log_rate_limit) } + let(:org) { VCAP::CloudController::Organization.make(quota_definition: org_quota_definition) } + let(:space_quota_definition) { VCAP::CloudController::SpaceQuotaDefinition.make(organization: org, log_rate_limit: space_log_rate_limit) } + let(:space) { VCAP::CloudController::Space.make(organization: org, space_quota_definition: space_quota_definition) } + let!(:process_model) { VCAP::CloudController::ProcessModel.make(app: app_model, log_rate_limit: log_rate_limit) } + let(:app_model) do + VCAP::CloudController::AppModel.make( + :buildpack, + name: 'app-name', + space: space, + desired_state: 'STOPPED' + ) + end + let(:droplet) { VCAP::CloudController::DropletModel.make(app: app_model, process_types: { web: 'webby' }) } + + before do + app_model.update(droplet_guid: droplet.guid) + end + + describe 'space quotas' do + context 'when both the space and the app do not specify a log rate limit' do + let(:log_rate_limit) { -1 } + let(:space_log_rate_limit) { -1 } + + it 'starts the app successfully' do + post "/v3/apps/#{app_model.guid}/actions/start", nil, admin_header + + expect(last_response.status).to eq(200) + end + end + + context "when the app fits in the space's log rate limit" do + let(:log_rate_limit) { 199 } + let(:space_log_rate_limit) { 200 } + + it 'starts the app successfully' do + post "/v3/apps/#{app_model.guid}/actions/start", nil, admin_header + + expect(last_response.status).to eq(200) + end + end + + context "when the app's log rate limit is unspecified, but the space specifies a log rate limit" do + let(:log_rate_limit) { -1 } + let(:space_log_rate_limit) { 200 } + + it 'fails to start the app' do + post "/v3/apps/#{app_model.guid}/actions/start", nil, admin_header + + expect(last_response.status).to eq(422) + expect(last_response).to have_error_message("log_rate_limit cannot be unlimited in space '#{space.name}'.") + end + end + + context "when the app's log rate limit is larger than the limit specified by the space" do + let(:log_rate_limit) { 201 } + let(:space_log_rate_limit) { 200 } + + it 'fails to start the app' do + post "/v3/apps/#{app_model.guid}/actions/start", nil, admin_header + + expect(last_response.status).to eq(422) + expect(last_response).to have_error_message('log_rate_limit exceeds space log rate quota') + end + end + + context "when the space's quota is more strict that the org's quota, the space quota controls" do + let(:log_rate_limit) { 201 } + let(:space_log_rate_limit) { 200 } + let(:org_log_rate_limit) { 201 } + + it 'fails to start the app' do + post "/v3/apps/#{app_model.guid}/actions/start", nil, admin_header + + expect(last_response.status).to eq(422) + expect(last_response).to have_error_message('log_rate_limit exceeds space log rate quota') + end + end + end + + describe 'organization quotas' do + context 'when both the org and the app do not specify a log rate limit' do + let(:log_rate_limit) { -1 } + let(:org_log_rate_limit) { -1 } + + it 'starts the app successfully' do + post "/v3/apps/#{app_model.guid}/actions/start", nil, admin_header + + expect(last_response.status).to eq(200) + end + end + + context "when the app fits in the org's log rate limit" do + let(:log_rate_limit) { 199 } + let(:org_log_rate_limit) { 200 } + + it 'starts the app successfully' do + post "/v3/apps/#{app_model.guid}/actions/start", nil, admin_header + + expect(last_response.status).to eq(200) + end + end + + context "when the app's log rate limit is unspecified, but the org specifies a log rate limit" do + let(:log_rate_limit) { -1 } + let(:org_log_rate_limit) { 200 } + + it 'fails to start the app' do + post "/v3/apps/#{app_model.guid}/actions/start", nil, admin_header + + expect(last_response.status).to eq(422) + expect(last_response).to have_error_message("log_rate_limit cannot be unlimited in organization '#{org.name}'.") + end + end + + context "when the app's log rate limit is larger than the limit specified by the org" do + let(:log_rate_limit) { 201 } + let(:org_log_rate_limit) { 200 } + + it 'fails to start the app' do + post "/v3/apps/#{app_model.guid}/actions/start", nil, admin_header + + expect(last_response.status).to eq(422) + expect(last_response).to have_error_message('log_rate_limit exceeds organization log rate quota') + end + end + + context "when the org's quota is more strict that the space's quota, the org quota controls" do + let(:log_rate_limit) { 201 } + let(:space_log_rate_limit) { 202 } + let(:org_log_rate_limit) { 200 } + + it 'fails to start the app' do + post "/v3/apps/#{app_model.guid}/actions/start", nil, admin_header + + expect(last_response.status).to eq(422) + expect(last_response).to have_error_message('log_rate_limit exceeds organization log rate quota') + end + end + end + end + end + + context 'events' do + before do + space.organization.add_user(user) + space.add_developer(user) + end + + it 'issues the required events when the app starts' do + post "/v3/apps/#{app_model.guid}/actions/start", nil, user_header + + event = VCAP::CloudController::Event.last + expect(event.values).to include({ + type: 'audit.app.start', + actee: app_model.guid, + actee_type: 'app', + actee_name: 'app-name', + actor: user.guid, + actor_type: 'user', + actor_name: user_email, + actor_username: user_name, + space_guid: space.guid, + organization_guid: space.organization.guid + }) + end + end + + context 'telemetry' do + before do + space.organization.add_user(user) + space.add_developer(user) + end + + it 'logs the required fields when the app starts' do + Timecop.freeze do + expected_json = { + 'telemetry-source' => 'cloud_controller_ng', + 'telemetry-time' => Time.now.to_datetime.rfc3339, + 'start-app' => { + 'api-version' => 'v3', + 'app-id' => OpenSSL::Digest::SHA256.hexdigest(app_model.guid), + 'user-id' => OpenSSL::Digest::SHA256.hexdigest(user.guid) + } + } + expect_any_instance_of(ActiveSupport::Logger).to receive(:info).with(Oj.dump(expected_json)) + post "/v3/apps/#{app_model.guid}/actions/start", nil, user_header + + expect(last_response.status).to eq(200), last_response.body + end + end + end + end + + describe 'when there is a new desired droplet and revision feature is turned on' do + let(:droplet) do + VCAP::CloudController::DropletModel.make( + app: app_model, + process_types: { web: 'rackup' }, + state: VCAP::CloudController::DropletModel::STAGED_STATE, + package: VCAP::CloudController::PackageModel.make + ) + end + + before do + space.organization.add_user(user) + space.add_developer(user) + app_model.update(revisions_enabled: true) + end + + it 'creates a new revision' do + expect do + patch "/v3/apps/#{app_model.guid}/relationships/current_droplet", { data: { guid: droplet.guid } }.to_json, user_header + expect(last_response.status).to eq(200) + end.not_to(change(VCAP::CloudController::RevisionModel, :count)) + + expect do + post "/v3/apps/#{app_model.guid}/actions/start", nil, user_header + expect(last_response.status).to eq(200), last_response.body + end.to change(VCAP::CloudController::RevisionModel, :count).by(1) + end + end + end + + describe 'POST /v3/apps/:guid/actions/stop' do + let(:stack) { VCAP::CloudController::Stack.make(name: 'stack-name') } + let(:app_model) do + VCAP::CloudController::AppModel.make( + :buildpack, + name: 'app-name', + space: space, + desired_state: 'STARTED' + ) + end + let!(:droplet) do + VCAP::CloudController::DropletModel.make(:buildpack, + app: app_model, + state: VCAP::CloudController::DropletModel::STAGED_STATE) + end + + before do + app_model.lifecycle_data.buildpacks = ['http://example.com/git'] + app_model.lifecycle_data.stack = stack.name + app_model.lifecycle_data.save + app_model.droplet = droplet + app_model.save + end + + context 'stopping an app' do + let(:api_call) { ->(user_headers) { post "/v3/apps/#{app_model.guid}/actions/stop", nil, user_headers } } + let(:app_stop_response_object) do + { + 'name' => 'app-name', + 'guid' => app_model.guid, + 'state' => 'STOPPED', + 'created_at' => iso8601, + 'updated_at' => iso8601, + 'metadata' => { 'labels' => {}, 'annotations' => {} }, + 'lifecycle' => { + 'type' => 'buildpack', + 'data' => { + 'buildpacks' => ['http://example.com/git'], + 'stack' => 'stack-name' + } + }, + 'relationships' => { + 'space' => { + 'data' => { + 'guid' => space.guid + } + }, + 'current_droplet' => { + 'data' => { + 'guid' => droplet.guid + } + } + }, + 'links' => { + 'self' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}" }, + 'processes' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/processes" }, + 'packages' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/packages" }, + 'environment_variables' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/environment_variables" }, + 'space' => { 'href' => "#{link_prefix}/v3/spaces/#{space.guid}" }, + 'current_droplet' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/droplets/current" }, + 'droplets' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/droplets" }, + 'tasks' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/tasks" }, + 'start' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/start", 'method' => 'POST' }, + 'stop' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/stop", 'method' => 'POST' }, + 'clear_buildpack_cache' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/clear_buildpack_cache", 'method' => 'POST' }, + 'revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/revisions" }, + 'deployed_revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/revisions/deployed" }, + 'features' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/features" } + } + } + end + + let(:expected_codes_and_responses) do + h = Hash.new({ code: 403, errors: CF_NOT_AUTHORIZED }.freeze) + h['no_role'] = { code: 404 } + h['org_auditor'] = { code: 404 } + h['org_billing_manager'] = { code: 404 } + h['admin'] = { + code: 200, + response_object: app_stop_response_object + } + h['space_supporter'] = { + code: 200, + response_object: app_stop_response_object + } + h['space_developer'] = { + code: 200, + response_object: app_stop_response_object + } + h + end + + before do + space.organization.add_user(user) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + + context 'when organization is suspended' do + let(:expected_codes_and_responses) do + h = super() + %w[space_supporter space_developer].each { |r| h[r] = { code: 403, errors: CF_ORG_SUSPENDED } } + h + end + + before do + org.update(status: VCAP::CloudController::Organization::SUSPENDED) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + end + + context 'events' do + before do + space.organization.add_user(user) + space.add_developer(user) + end + + it 'issues the required events when the app stops' do + post "/v3/apps/#{app_model.guid}/actions/stop", nil, user_header + + event = VCAP::CloudController::Event.last + expect(event.values).to include({ + type: 'audit.app.stop', + actee: app_model.guid, + actee_type: 'app', + actee_name: 'app-name', + actor: user.guid, + actor_type: 'user', + actor_name: user_email, + actor_username: user_name, + space_guid: space.guid, + organization_guid: space.organization.guid + }) + end + end + + context 'telemetry' do + before do + space.organization.add_user(user) + space.add_developer(user) + end + + it 'logs the required fields when the app stops' do + Timecop.freeze do + expected_json = { + 'telemetry-source' => 'cloud_controller_ng', + 'telemetry-time' => Time.now.to_datetime.rfc3339, + 'stop-app' => { + 'api-version' => 'v3', + 'app-id' => OpenSSL::Digest::SHA256.hexdigest(app_model.guid), + 'user-id' => OpenSSL::Digest::SHA256.hexdigest(user.guid) + } + } + expect_any_instance_of(ActiveSupport::Logger).to receive(:info).with(Oj.dump(expected_json)) + + post "/v3/apps/#{app_model.guid}/actions/stop", nil, user_header + + expect(last_response.status).to eq(200), last_response.body + end + end + end + end + + describe 'POST /v3/apps/:guid/actions/restart' do + let(:stack) { VCAP::CloudController::Stack.make(name: 'stack-name') } + let(:app_model) do + VCAP::CloudController::AppModel.make( + :buildpack, + name: 'app-name', + space: space, + desired_state: 'STARTED' + ) + end + + context 'app lifecycle is buildpack' do + let!(:droplet) do + VCAP::CloudController::DropletModel.make( + :buildpack, + app: app_model, + state: VCAP::CloudController::DropletModel::STAGED_STATE + ) + end + + before do + app_model.lifecycle_data.buildpacks = ['http://example.com/git'] + app_model.lifecycle_data.stack = stack.name + app_model.lifecycle_data.save + app_model.droplet = droplet + app_model.save + end + + context 'restarting an app' do + let(:api_call) { ->(user_headers) { post "/v3/apps/#{app_model.guid}/actions/restart", nil, user_headers } } + let(:app_restart_response_object) do + { + 'name' => 'app-name', + 'guid' => app_model.guid, + 'state' => 'STARTED', + 'created_at' => iso8601, + 'updated_at' => iso8601, + 'metadata' => { 'labels' => {}, 'annotations' => {} }, + 'lifecycle' => { + 'type' => 'buildpack', + 'data' => { + 'buildpacks' => ['http://example.com/git'], + 'stack' => 'stack-name' + } + }, + 'relationships' => { + 'space' => { + 'data' => { + 'guid' => space.guid + } + }, + 'current_droplet' => { + 'data' => { + 'guid' => droplet.guid + } + } + }, + 'links' => { + 'self' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}" }, + 'processes' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/processes" }, + 'packages' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/packages" }, + 'environment_variables' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/environment_variables" }, + 'space' => { 'href' => "#{link_prefix}/v3/spaces/#{space.guid}" }, + 'current_droplet' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/droplets/current" }, + 'droplets' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/droplets" }, + 'tasks' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/tasks" }, + 'start' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/start", 'method' => 'POST' }, + 'stop' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/stop", 'method' => 'POST' }, + 'clear_buildpack_cache' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/clear_buildpack_cache", 'method' => 'POST' }, + 'revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/revisions" }, + 'deployed_revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/revisions/deployed" }, + 'features' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/features" } + } + } + end + + let(:expected_codes_and_responses) do + h = Hash.new({ code: 403, errors: CF_NOT_AUTHORIZED }.freeze) + h['no_role'] = { code: 404 } + h['org_auditor'] = { code: 404 } + h['org_billing_manager'] = { code: 404 } + h['admin'] = { + code: 200, + response_object: app_restart_response_object + } + h['space_supporter'] = { + code: 200, + response_object: app_restart_response_object + } + h['space_developer'] = { + code: 200, + response_object: app_restart_response_object + } + h + end + + before do + space.organization.add_user(user) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + + context 'when organization is suspended' do + let(:expected_codes_and_responses) do + h = super() + %w[space_supporter space_developer].each { |r| h[r] = { code: 403, errors: CF_ORG_SUSPENDED } } + h + end + + before do + org.update(status: VCAP::CloudController::Organization::SUSPENDED) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + end + + context 'telemetry' do + before do + space.organization.add_user(user) + space.add_developer(user) + end + + it 'logs the required fields when the app is restarted' do + Timecop.freeze do + expected_json = { + 'telemetry-source' => 'cloud_controller_ng', + 'telemetry-time' => Time.now.to_datetime.rfc3339, + 'restart-app' => { + 'api-version' => 'v3', + 'app-id' => OpenSSL::Digest::SHA256.hexdigest(app_model.guid), + 'user-id' => OpenSSL::Digest::SHA256.hexdigest(user.guid) + } + } + expect_any_instance_of(ActiveSupport::Logger).to receive(:info).with(Oj.dump(expected_json)) + + post "/v3/apps/#{app_model.guid}/actions/restart", nil, user_header + + expect(last_response.status).to eq(200), last_response.body + end + end + end + end + end +end diff --git a/spec/request/apps/builds_and_ssh_spec.rb b/spec/request/apps/builds_and_ssh_spec.rb new file mode 100644 index 00000000000..9fe458d90e1 --- /dev/null +++ b/spec/request/apps/builds_and_ssh_spec.rb @@ -0,0 +1,222 @@ +require 'spec_helper' +require 'actions/process_create_from_app_droplet' +require 'request_spec_shared_examples' +require_relative 'shared_context' + +# Split from spec/request/apps_spec.rb for better test parallelization + +RSpec.describe 'Apps' do + include_context 'apps request spec' + + describe 'GET /v3/apps/:guid/builds' do + let(:app_model) { VCAP::CloudController::AppModel.make(space: space, name: 'my-app') } + let(:build) do + VCAP::CloudController::BuildModel.make( + package: package, + app: app_model, + staging_memory_in_mb: 123, + staging_disk_in_mb: 456, + staging_log_rate_limit: 789, + created_by_user_name: 'bob the builder', + created_by_user_guid: user.guid, + created_by_user_email: 'bob@loblaw.com' + ) + end + let!(:second_build) do + VCAP::CloudController::BuildModel.make( + package: package, + app: app_model, + staging_memory_in_mb: 123, + staging_disk_in_mb: 456, + staging_log_rate_limit: 789, + created_at: build.created_at - 1.day, + created_by_user_name: 'bob the builder', + created_by_user_guid: user.guid, + created_by_user_email: 'bob@loblaw.com' + ) + end + let(:package) { VCAP::CloudController::PackageModel.make(app_guid: app_model.guid) } + let(:droplet) do + VCAP::CloudController::DropletModel.make( + state: VCAP::CloudController::DropletModel::STAGED_STATE, + package_guid: package.guid, + build: build + ) + end + let(:second_droplet) do + VCAP::CloudController::DropletModel.make( + state: VCAP::CloudController::DropletModel::STAGED_STATE, + package_guid: package.guid, + build: second_build + ) + end + let(:body) do + { + lifecycle: { + type: 'buildpack', + data: { + buildpacks: ['http://github.com/myorg/awesome-buildpack'], + stack: 'cflinuxfs4' + } + } + } + end + + describe 'permissions' do + let(:api_call) do + ->(headers) { get "/v3/apps/#{app_model.guid}/builds", nil, headers } + end + let(:expected_codes_and_responses) do + h = Hash.new({ code: 200, response_guids: [build.guid, second_build.guid] }.freeze) + h['org_auditor'] = { code: 404 } + h['org_billing_manager'] = { code: 404 } + h['no_role'] = { code: 404 } + h + end + + it_behaves_like 'permissions for list endpoint', ALL_PERMISSIONS + end + + describe 'as a developer' do + let(:staging_message) { VCAP::CloudController::BuildCreateMessage.new(body) } + let(:per_page) { 2 } + let(:order_by) { '-created_at' } + + before do + space.organization.add_user(user) + space.add_developer(user) + VCAP::CloudController::BuildpackLifecycle.new(package, staging_message).create_lifecycle_data_model(build) + VCAP::CloudController::BuildpackLifecycle.new(package, staging_message).create_lifecycle_data_model(second_build) + build.update(state: droplet.state, error_description: droplet.error_description) + second_build.update(state: second_droplet.state, error_description: second_droplet.error_description) + end + + it 'lists the builds for app' do + get "v3/apps/#{app_model.guid}/builds?order_by=#{order_by}&per_page=#{per_page}", nil, user_header + + parsed_response = Oj.load(last_response.body) + + expect(last_response.status).to eq(200) + expect(parsed_response['resources']).to include(hash_including('guid' => build.guid)) + expect(parsed_response['resources']).to include(hash_including('guid' => second_build.guid)) + expect(parsed_response).to be_a_response_like({ + 'pagination' => { + 'total_results' => 2, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/builds?order_by=#{order_by}&page=1&per_page=2" }, + 'last' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/builds?order_by=#{order_by}&page=1&per_page=2" }, + 'next' => nil, + 'previous' => nil + }, + 'resources' => [ + { + 'guid' => build.guid, + 'created_at' => iso8601, + 'updated_at' => iso8601, + 'state' => 'STAGED', + 'error' => nil, + 'staging_memory_in_mb' => 123, + 'staging_disk_in_mb' => 456, + 'staging_log_rate_limit_bytes_per_second' => 789, + 'lifecycle' => { + 'type' => 'buildpack', + 'data' => { + 'buildpacks' => ['http://github.com/myorg/awesome-buildpack'], + 'stack' => 'cflinuxfs4' + } + }, + 'package' => { 'guid' => package.guid }, + 'droplet' => { + 'guid' => droplet.guid + }, + 'relationships' => { 'app' => { 'data' => { 'guid' => app_model.guid } } }, + 'metadata' => { 'labels' => {}, 'annotations' => {} }, + 'links' => { + 'self' => { 'href' => "#{link_prefix}/v3/builds/#{build.guid}" }, + 'app' => { 'href' => "#{link_prefix}/v3/apps/#{package.app.guid}" }, + 'droplet' => { 'href' => "#{link_prefix}/v3/droplets/#{droplet.guid}" } + }, + 'created_by' => { 'guid' => user.guid, 'name' => 'bob the builder', 'email' => 'bob@loblaw.com' } + }, + { + 'guid' => second_build.guid, + 'created_at' => iso8601, + 'updated_at' => iso8601, + 'state' => 'STAGED', + 'error' => nil, + 'staging_memory_in_mb' => 123, + 'staging_disk_in_mb' => 456, + 'staging_log_rate_limit_bytes_per_second' => 789, + 'lifecycle' => { + 'type' => 'buildpack', + 'data' => { + 'buildpacks' => ['http://github.com/myorg/awesome-buildpack'], + 'stack' => 'cflinuxfs4' + } + }, + 'package' => { 'guid' => package.guid }, + 'droplet' => { + 'guid' => second_droplet.guid + }, + 'relationships' => { 'app' => { 'data' => { 'guid' => app_model.guid } } }, + 'metadata' => { 'labels' => {}, 'annotations' => {} }, + 'links' => { + 'self' => { 'href' => "#{link_prefix}/v3/builds/#{second_build.guid}" }, + 'app' => { 'href' => "#{link_prefix}/v3/apps/#{package.app.guid}" }, + 'droplet' => { 'href' => "#{link_prefix}/v3/droplets/#{second_droplet.guid}" } + }, + 'created_by' => { 'guid' => user.guid, 'name' => 'bob the builder', 'email' => 'bob@loblaw.com' } + } + ] + }) + end + + it_behaves_like 'list_endpoint_with_common_filters' do + let(:resource_klass) { VCAP::CloudController::BuildModel } + let(:additional_resource_params) { { app: app_model } } + let(:api_call) do + ->(headers, filters) { get "/v3/apps/#{app_model.guid}/builds?#{filters}", nil, headers } + end + let(:headers) { admin_header } + end + + it 'filters on label_selector' do + VCAP::CloudController::BuildLabelModel.make(key_name: 'fruit', value: 'strawberry', build: build) + + get "/v3/apps/#{app_model.guid}/builds?label_selector=fruit=strawberry", {}, user_header + + expect(last_response.status).to eq(200) + expect(parsed_response['resources'].count).to eq(1) + expect(parsed_response['resources'][0]['guid']).to eq(build.guid) + end + end + end + + describe 'GET /v3/apps/:guid/ssh_enabled' do + before do + space.organization.add_user(user) + end + + context 'when getting an apps ssh_enabled value' do + let(:api_call) { ->(user_headers) { get "/v3/apps/#{app_model.guid}/ssh_enabled", nil, user_headers } } + let!(:app_model) do + VCAP::CloudController::AppModel.make( + :buildpack, + name: 'my_app', + guid: 'app1_guid', + space: space + ) + end + + let(:expected_codes_and_responses) do + h = Hash.new({ code: 200 }.freeze) + h['org_auditor'] = { code: 404 } + h['org_billing_manager'] = { code: 404 } + h['no_role'] = { code: 404 } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + end +end diff --git a/spec/request/apps/create_spec.rb b/spec/request/apps/create_spec.rb new file mode 100644 index 00000000000..fea0470cd57 --- /dev/null +++ b/spec/request/apps/create_spec.rb @@ -0,0 +1,451 @@ +require 'spec_helper' +require 'actions/process_create_from_app_droplet' +require 'request_spec_shared_examples' +require_relative 'shared_context' + +# Split from spec/request/apps_spec.rb for better test parallelization + +RSpec.describe 'Apps' do + include_context 'apps request spec' + + describe 'POST /v3/apps' do + let(:buildpack) { VCAP::CloudController::Buildpack.make(stack: stack.name) } + let(:create_request) do + { + name: 'my_app', + environment_variables: { open: 'source' }, + lifecycle: { + type: 'buildpack', + data: { + stack: buildpack.stack, + buildpacks: [buildpack.name] + } + }, + relationships: { + space: { + data: { + guid: space.guid + } + } + }, + metadata: { + labels: { + 'release' => 'stable', + 'code.cloudfoundry.org/cloud_controller_ng' => 'awesome' + }, + annotations: { + 'description' => 'gud app', + 'dora.capi.land/stuff' => 'real gud stuff' + } + } + } + end + + context 'permissions for creating an app' do + let(:api_call) { ->(user_headers) { post '/v3/apps', create_request.to_json, user_headers } } + let(:app_model_response_object) do + { + guid: UUID_REGEX, + created_at: iso8601, + updated_at: iso8601, + name: 'my_app', + state: 'STOPPED', + lifecycle: { + type: 'buildpack', + data: { buildpacks: [buildpack.name], stack: stack.name } + }, + relationships: { + space: { data: { guid: space.guid } }, + current_droplet: { data: { guid: nil } } + }, + metadata: { + labels: { + 'code.cloudfoundry.org/cloud_controller_ng' => 'awesome', + 'release' => 'stable' + }, + annotations: { + 'dora.capi.land/stuff' => 'real gud stuff', + 'description' => 'gud app' + } + }, + links: { + self: { href: %r{#{link_prefix}/v3/apps/#{UUID_REGEX}} }, + environment_variables: { href: %r{#{link_prefix}/v3/apps/#{UUID_REGEX}/environment_variables} }, + space: { href: %r{#{link_prefix}/v3/spaces/#{space.guid}} }, + processes: { href: %r{#{link_prefix}/v3/apps/#{UUID_REGEX}/processes} }, + packages: { href: %r{#{link_prefix}/v3/apps/#{UUID_REGEX}/packages} }, + current_droplet: { href: %r{#{link_prefix}/v3/apps/#{UUID_REGEX}/droplets/current} }, + droplets: { href: %r{#{link_prefix}/v3/apps/#{UUID_REGEX}/droplets} }, + tasks: { href: %r{#{link_prefix}/v3/apps/#{UUID_REGEX}/tasks} }, + start: { href: %r{#{link_prefix}/v3/apps/#{UUID_REGEX}/actions/start}, method: 'POST' }, + stop: { href: %r{#{link_prefix}/v3/apps/#{UUID_REGEX}/actions/stop}, method: 'POST' }, + clear_buildpack_cache: { href: %r{#{link_prefix}/v3/apps/#{UUID_REGEX}/actions/clear_buildpack_cache}, method: 'POST' }, + revisions: { href: %r{#{link_prefix}/v3/apps/#{UUID_REGEX}/revisions} }, + deployed_revisions: { href: %r{#{link_prefix}/v3/apps/#{UUID_REGEX}/revisions/deployed} }, + features: { href: %r{#{link_prefix}/v3/apps/#{UUID_REGEX}/features} } + } + } + end + + let(:expected_codes_and_responses) do + h = Hash.new({ code: 403, errors: CF_NOT_AUTHORIZED }.freeze) + h['org_billing_manager'] = { code: 422 } + h['org_auditor'] = { code: 422 } + h['no_role'] = { code: 422 } + h['admin'] = { + code: 201, + response_object: app_model_response_object + } + h['space_developer'] = { + code: 201, + response_object: app_model_response_object + } + h + end + + before do + space.organization.add_user(user) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + + context 'when organization is suspended' do + let(:expected_codes_and_responses) do + h = super() + h['space_developer'] = { code: 403, errors: CF_ORG_SUSPENDED } + h + end + + before do + org.update(status: VCAP::CloudController::Organization::SUSPENDED) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + end + + context 'when the user can create an app' do + before do + space.organization.add_user(user) + space.add_developer(user) + end + + it 'creates an app' do + post '/v3/apps', create_request.to_json, user_header + expect(last_response.status).to eq(201) + + parsed_response = Oj.load(last_response.body) + app_guid = parsed_response['guid'] + + expect(VCAP::CloudController::AppModel.find(guid: app_guid)).to be + expect(parsed_response).to be_a_response_like( + { + 'name' => 'my_app', + 'guid' => app_guid, + 'state' => 'STOPPED', + 'lifecycle' => { + 'type' => 'buildpack', + 'data' => { + 'buildpacks' => [buildpack.name], + 'stack' => stack.name + } + }, + 'relationships' => { + 'space' => { + 'data' => { + 'guid' => space.guid + } + }, + 'current_droplet' => { + 'data' => { + 'guid' => nil + } + } + }, + 'created_at' => iso8601, + 'updated_at' => iso8601, + 'metadata' => { + 'labels' => { + 'release' => 'stable', + 'code.cloudfoundry.org/cloud_controller_ng' => 'awesome' + }, + 'annotations' => { + 'description' => 'gud app', + 'dora.capi.land/stuff' => 'real gud stuff' + } + }, + 'links' => { + 'self' => { 'href' => "#{link_prefix}/v3/apps/#{app_guid}" }, + 'processes' => { 'href' => "#{link_prefix}/v3/apps/#{app_guid}/processes" }, + 'packages' => { 'href' => "#{link_prefix}/v3/apps/#{app_guid}/packages" }, + 'environment_variables' => { 'href' => "#{link_prefix}/v3/apps/#{app_guid}/environment_variables" }, + 'space' => { 'href' => "#{link_prefix}/v3/spaces/#{space.guid}" }, + 'current_droplet' => { 'href' => "#{link_prefix}/v3/apps/#{app_guid}/droplets/current" }, + 'droplets' => { 'href' => "#{link_prefix}/v3/apps/#{app_guid}/droplets" }, + 'tasks' => { 'href' => "#{link_prefix}/v3/apps/#{app_guid}/tasks" }, + 'start' => { 'href' => "#{link_prefix}/v3/apps/#{app_guid}/actions/start", 'method' => 'POST' }, + 'stop' => { 'href' => "#{link_prefix}/v3/apps/#{app_guid}/actions/stop", 'method' => 'POST' }, + 'clear_buildpack_cache' => { 'href' => "#{link_prefix}/v3/apps/#{app_guid}/actions/clear_buildpack_cache", 'method' => 'POST' }, + 'revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_guid}/revisions" }, + 'deployed_revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_guid}/revisions/deployed" }, + 'features' => { 'href' => "#{link_prefix}/v3/apps/#{app_guid}/features" } + } + } + ) + + event = VCAP::CloudController::Event.last + expect(event.values).to include({ + type: 'audit.app.create', + actee: app_guid, + actee_type: 'app', + actee_name: 'my_app', + actor: user.guid, + actor_type: 'user', + actor_name: user_email, + actor_username: user_name, + space_guid: space.guid, + organization_guid: space.organization.guid + }) + end + + it 'creates an empty web process with the same guid as the app (so it is visible on the v2 apps api)' do + post '/v3/apps', create_request.to_json, user_header + expect(last_response.status).to eq(201) + + parsed_response = Oj.load(last_response.body) + app_guid = parsed_response['guid'] + expect(VCAP::CloudController::AppModel.find(guid: app_guid)).not_to be_nil + expect(VCAP::CloudController::ProcessModel.find(guid: app_guid)).not_to be_nil + end + + context 'telemetry' do + let(:logger_spy) { spy('logger') } + + before do + allow(VCAP::CloudController::TelemetryLogger).to receive(:logger).and_return(logger_spy) + end + + it 'logs the required fields when the app is created' do + Timecop.freeze do + post '/v3/apps', create_request.to_json, user_header + + parsed_response = Oj.load(last_response.body) + app_guid = parsed_response['guid'] + + expected_json = { + 'telemetry-source' => 'cloud_controller_ng', + 'telemetry-time' => Time.now.to_datetime.rfc3339, + 'create-app' => { + 'api-version' => 'v3', + 'app-id' => OpenSSL::Digest::SHA256.hexdigest(app_guid), + 'user-id' => OpenSSL::Digest::SHA256.hexdigest(user.guid) + } + }.to_json + expect(logger_spy).to have_received(:info).with(expected_json) + expect(last_response.status).to eq(201), last_response.body + end + end + end + + context 'Docker app' do + before do + VCAP::CloudController::FeatureFlag.make(name: 'diego_docker', enabled: true, error_message: nil) + end + + it 'create a docker app' do + create_request = { + name: 'my_app', + environment_variables: { open: 'source' }, + lifecycle: { + type: 'docker', + data: {} + }, + relationships: { + space: { data: { guid: space.guid } } + } + } + + post '/v3/apps', create_request.to_json, user_header.merge({ 'CONTENT_TYPE' => 'application/json' }) + expect(last_response.status).to eq(201), last_response.body + + created_app = VCAP::CloudController::AppModel.last + expected_response = { + 'name' => 'my_app', + 'guid' => created_app.guid, + 'state' => 'STOPPED', + 'lifecycle' => { + 'type' => 'docker', + 'data' => {} + }, + 'relationships' => { + 'space' => { + 'data' => { + 'guid' => space.guid + } + }, + 'current_droplet' => { + 'data' => { + 'guid' => nil + } + } + }, + 'created_at' => iso8601, + 'updated_at' => iso8601, + 'metadata' => { 'labels' => {}, 'annotations' => {} }, + 'links' => { + 'self' => { 'href' => "#{link_prefix}/v3/apps/#{created_app.guid}" }, + 'processes' => { 'href' => "#{link_prefix}/v3/apps/#{created_app.guid}/processes" }, + 'packages' => { 'href' => "#{link_prefix}/v3/apps/#{created_app.guid}/packages" }, + 'environment_variables' => { 'href' => "#{link_prefix}/v3/apps/#{created_app.guid}/environment_variables" }, + 'space' => { 'href' => "#{link_prefix}/v3/spaces/#{space.guid}" }, + 'current_droplet' => { 'href' => "#{link_prefix}/v3/apps/#{created_app.guid}/droplets/current" }, + 'droplets' => { 'href' => "#{link_prefix}/v3/apps/#{created_app.guid}/droplets" }, + 'tasks' => { 'href' => "#{link_prefix}/v3/apps/#{created_app.guid}/tasks" }, + 'start' => { 'href' => "#{link_prefix}/v3/apps/#{created_app.guid}/actions/start", 'method' => 'POST' }, + 'stop' => { 'href' => "#{link_prefix}/v3/apps/#{created_app.guid}/actions/stop", 'method' => 'POST' }, + 'clear_buildpack_cache' => { 'href' => "#{link_prefix}/v3/apps/#{created_app.guid}/actions/clear_buildpack_cache", 'method' => 'POST' }, + 'revisions' => { 'href' => "#{link_prefix}/v3/apps/#{created_app.guid}/revisions" }, + 'deployed_revisions' => { 'href' => "#{link_prefix}/v3/apps/#{created_app.guid}/revisions/deployed" }, + 'features' => { 'href' => "#{link_prefix}/v3/apps/#{created_app.guid}/features" } + } + } + + parsed_response = Oj.load(last_response.body) + expect(parsed_response).to be_a_response_like(expected_response) + + event = VCAP::CloudController::Event.last + expect(event.values).to include( + type: 'audit.app.create', + actee: created_app.guid, + actee_type: 'app', + actee_name: 'my_app', + actor: user.guid, + actor_type: 'user', + actor_name: user_email, + actor_username: user_name, + space_guid: space.guid, + organization_guid: space.organization.guid + ) + end + end + + context 'cc.default_app_lifecycle' do + let(:create_request) do + { + name: 'my_app', + relationships: { + space: { + data: { + guid: space.guid + } + } + } + } + end + + context 'cc.default_app_lifecycle is set to buildpack' do + before do + TestConfig.override(default_app_lifecycle: 'buildpack') + end + + it 'creates an app with the buildpack lifecycle when none is specified in the request' do + post '/v3/apps', create_request.to_json, user_header + + expect(last_response.status).to eq(201) + parsed_response = Oj.load(last_response.body) + expect(parsed_response['lifecycle']['type']).to eq('buildpack') + end + end + end + end + + context 'stack state validation' do + before do + space.organization.add_user(user) + space.add_developer(user) + end + + context 'when stack is DISABLED' do + let(:disabled_stack) { VCAP::CloudController::Stack.make(name: 'disabled-stack', state: 'DISABLED') } + let(:create_request) do + { + name: 'my_app', + lifecycle: { type: 'buildpack', data: { stack: disabled_stack.name } }, + relationships: { space: { data: { guid: space.guid } } } + } + end + + it 'returns 422 with error message' do + post '/v3/apps', create_request.to_json, user_header + + expect(last_response.status).to eq(422) + expect(parsed_response['errors'].first['detail']).to include('DISABLED') + end + end + + context 'when stack is RESTRICTED' do + let(:restricted_stack) { VCAP::CloudController::Stack.make(name: 'restricted-stack', state: 'RESTRICTED') } + let(:create_request) do + { + name: 'my_app', + lifecycle: { type: 'buildpack', data: { stack: restricted_stack.name } }, + relationships: { space: { data: { guid: space.guid } } } + } + end + + it 'returns 422 with error message for new apps' do + post '/v3/apps', create_request.to_json, user_header + + expect(last_response.status).to eq(422) + expect(parsed_response['errors'].first['detail']).to include('RESTRICTED') + end + end + + context 'when stack is DEPRECATED' do + let(:deprecated_stack) { VCAP::CloudController::Stack.make(name: 'deprecated-stack', state: 'DEPRECATED') } + let(:create_request) do + { + name: 'my_app', + lifecycle: { type: 'buildpack', data: { stack: deprecated_stack.name } }, + relationships: { space: { data: { guid: space.guid } } } + } + end + + it 'creates the app without warnings in response body' do + post '/v3/apps', create_request.to_json, user_header + + expect(last_response.status).to eq(201) + expect(parsed_response).not_to have_key('warnings') + end + + it 'includes warnings in X-Cf-Warnings header' do + post '/v3/apps', create_request.to_json, user_header + + expect(last_response.status).to eq(201) + expect(last_response.headers['X-Cf-Warnings']).to be_present + decoded_warning = CGI.unescape(last_response.headers['X-Cf-Warnings']) + expect(decoded_warning).to include('DEPRECATED') + end + end + + context 'when stack is ACTIVE' do + let(:active_stack) { VCAP::CloudController::Stack.make(name: 'active-stack', state: 'ACTIVE') } + let(:create_request) do + { + name: 'my_app', + lifecycle: { type: 'buildpack', data: { stack: active_stack.name } }, + relationships: { space: { data: { guid: space.guid } } } + } + end + + it 'creates the app without warnings' do + post '/v3/apps', create_request.to_json, user_header + + expect(last_response.status).to eq(201) + expect(parsed_response).not_to have_key('warnings') + expect(last_response.headers['X-Cf-Warnings']).to be_nil + end + end + end + end +end diff --git a/spec/request/apps/delete_and_update_spec.rb b/spec/request/apps/delete_and_update_spec.rb new file mode 100644 index 00000000000..1758648aeeb --- /dev/null +++ b/spec/request/apps/delete_and_update_spec.rb @@ -0,0 +1,329 @@ +require 'spec_helper' +require 'actions/process_create_from_app_droplet' +require 'request_spec_shared_examples' +require_relative 'shared_context' + +# Split from spec/request/apps_spec.rb for better test parallelization + +RSpec.describe 'Apps' do + include_context 'apps request spec' + + describe 'DELETE /v3/apps/guid' do + let!(:app_model) { VCAP::CloudController::AppModel.make(name: 'app_name', space: space) } + let!(:package) { VCAP::CloudController::PackageModel.make(app: app_model) } + let!(:droplet) { VCAP::CloudController::DropletModel.make(package: package, app: app_model) } + let!(:process) { VCAP::CloudController::ProcessModel.make(app: app_model) } + let!(:deployment) { VCAP::CloudController::DeploymentModel.make(app: app_model) } + let(:user_email) { nil } + + it 'deletes an App' do + space.organization.add_user(user) + space.add_developer(user) + delete "/v3/apps/#{app_model.guid}", nil, user_header + + expect(last_response.status).to eq(202) + expect(last_response.headers['Location']).to match(%r{/v3/jobs/#{VCAP::CloudController::PollableJobModel.last.guid}}) + + Delayed::Worker.new.work_off + + expect(app_model).not_to exist + expect(package).not_to exist + expect(droplet).not_to exist + expect(process).not_to exist + expect(deployment).not_to exist + + event = VCAP::CloudController::Event.last(2).first + expect(event.values).to include({ + type: 'audit.app.delete-request', + actee: app_model.guid, + actee_type: 'app', + actee_name: 'app_name', + actor: user.guid, + actor_type: 'user', + actor_name: '', + actor_username: user_name, + space_guid: space.guid, + organization_guid: space.organization.guid + }) + end + + context 'permissions for deleting an app' do + let(:api_call) { ->(user_headers) { delete "/v3/apps/#{app_model.guid}", nil, user_headers } } + let(:expected_codes_and_responses) do + h = Hash.new({ code: 202 }.freeze) + %w[admin_read_only global_auditor org_manager space_auditor space_manager space_supporter].each do |r| + h[r] = { code: 403, errors: CF_NOT_AUTHORIZED } + end + h['org_auditor'] = { code: 404 } + h['org_billing_manager'] = { code: 404 } + h['no_role'] = { code: 404 } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + + context 'when organization is suspended' do + let(:expected_codes_and_responses) do + h = super() + h['space_developer'] = { code: 403, errors: CF_ORG_SUSPENDED } + h + end + + before do + org.update(status: VCAP::CloudController::Organization::SUSPENDED) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + end + + context 'deleting metadata' do + before do + space.organization.add_user(user) + space.add_developer(user) + end + + it_behaves_like 'resource with metadata' do + let(:resource) { app_model } + let(:api_call) do + -> { delete "/v3/apps/#{resource.guid}", nil, user_header } + end + end + end + end + + describe 'PATCH /v3/apps/:guid' do + let(:app_model) do + VCAP::CloudController::AppModel.make( + :buildpack, + name: 'original_name', + space: space, + environment_variables: { 'ORIGINAL' => 'ENVAR' }, + desired_state: 'STOPPED' + ) + end + let!(:web_process) { VCAP::CloudController::ProcessModel.make(app: app_model, state: VCAP::CloudController::ProcessModel::STOPPED) } + let(:stack) { VCAP::CloudController::Stack.make(name: 'redhat') } + + let(:update_request) do + { + name: 'new-name', + lifecycle: { + type: 'buildpack', + data: { + buildpacks: ['http://gitwheel.org/my-app'], + stack: stack.name + } + }, + metadata: { + labels: { + 'release' => 'stable', + 'code.cloudfoundry.org/cloud_controller_ng' => 'awesome', + 'delete-me' => nil + }, + annotations: { + 'contacts' => 'Bill tel(1111111) email(bill@fixme), Bob tel(222222) pager(3333333#555) email(bob@fixme)', + 'anno1' => 'new-value', + 'please' => nil + } + } + } + end + + let(:expected_response_object) do + { + 'name' => 'new-name', + 'guid' => app_model.guid, + 'state' => 'STOPPED', + 'lifecycle' => { + 'type' => 'buildpack', + 'data' => { + 'buildpacks' => ['http://gitwheel.org/my-app'], + 'stack' => stack.name + } + }, + 'relationships' => { + 'space' => { + 'data' => { + 'guid' => space.guid + } + }, + 'current_droplet' => { + 'data' => { + 'guid' => nil + } + } + }, + 'created_at' => iso8601, + 'updated_at' => iso8601, + 'metadata' => { + 'labels' => { + 'release' => 'stable', + 'code.cloudfoundry.org/cloud_controller_ng' => 'awesome' + }, + 'annotations' => { + 'contacts' => 'Bill tel(1111111) email(bill@fixme), Bob tel(222222) pager(3333333#555) email(bob@fixme)', + 'anno1' => 'new-value' + } + }, + 'links' => { + 'self' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}" }, + 'processes' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/processes" }, + 'packages' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/packages" }, + 'environment_variables' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/environment_variables" }, + 'space' => { 'href' => "#{link_prefix}/v3/spaces/#{space.guid}" }, + 'current_droplet' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/droplets/current" }, + 'droplets' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/droplets" }, + 'tasks' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/tasks" }, + 'start' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/start", 'method' => 'POST' }, + 'stop' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/stop", 'method' => 'POST' }, + 'clear_buildpack_cache' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/clear_buildpack_cache", 'method' => 'POST' }, + 'revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/revisions" }, + 'deployed_revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/revisions/deployed" }, + 'features' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/features" } + } + } + end + + before do + VCAP::CloudController::AppLabelModel.make( + resource_guid: app_model.guid, + key_name: 'delete-me', + value: 'yes' + ) + + VCAP::CloudController::AppAnnotationModel.make( + resource_guid: app_model.guid, + key_name: 'anno1', + value: 'original-value' + ) + + VCAP::CloudController::AppAnnotationModel.make( + resource_guid: app_model.guid, + key_name: 'please', + value: 'delete this' + ) + end + + it 'updates an app' do + space.organization.add_user(user) + space.add_developer(user) + expect_any_instance_of(VCAP::CloudController::Diego::Runner).not_to receive(:update_metric_tags) + patch "/v3/apps/#{app_model.guid}", update_request.to_json, user_header + expect(last_response.status).to eq(200) + + app_model.reload + + parsed_response = Oj.load(last_response.body) + expect(parsed_response).to be_a_response_like(expected_response_object) + + event = VCAP::CloudController::Event.last + expect(event.values).to include({ + type: 'audit.app.update', + actee: app_model.guid, + actee_type: 'app', + actee_name: 'new-name', + actor: user.guid, + actor_type: 'user', + actor_name: user_email, + actor_username: user_name, + space_guid: space.guid, + organization_guid: space.organization.guid + }) + metadata_request = { + 'name' => 'new-name', + 'lifecycle' => { + 'type' => 'buildpack', + 'data' => { + 'buildpacks' => ['http://gitwheel.org/my-app'], + 'stack' => stack.name + } + }, + 'metadata' => { + 'labels' => { + 'release' => 'stable', + 'code.cloudfoundry.org/cloud_controller_ng' => 'awesome', + 'delete-me' => nil + }, + 'annotations' => { + 'contacts' => 'Bill tel(1111111) email(bill@fixme), Bob tel(222222) pager(3333333#555) email(bob@fixme)', + 'anno1' => 'new-value', + 'please' => nil + } + } + } + expect(event.metadata['request']).to eq(metadata_request) + end + + context 'when the app has a process that is started' do + let!(:web_process) { VCAP::CloudController::ProcessModel.make(app: app_model, state: VCAP::CloudController::ProcessModel::STARTED) } + + before do + app_model.desired_state = VCAP::CloudController::ProcessModel::STARTED + end + + it 'notifies diego that an app has been renamed' do + space.organization.add_user(user) + space.add_developer(user) + expect_any_instance_of(VCAP::CloudController::Diego::Runner).to receive(:update_metric_tags) + patch "/v3/apps/#{app_model.guid}", update_request.to_json, user_header + expect(last_response.status).to eq(200) + end + end + + context 'permissions for updating an app' do + let(:api_call) { ->(user_headers) { patch "/v3/apps/#{app_model.guid}", update_request.to_json, user_headers } } + let(:expected_codes_and_responses) do + h = Hash.new({ code: 200, response_object: expected_response_object }.freeze) + %w[admin_read_only global_auditor org_manager space_auditor space_manager space_supporter].each do |r| + h[r] = { code: 403, errors: CF_NOT_AUTHORIZED } + end + h['org_auditor'] = { code: 404 } + h['org_billing_manager'] = { code: 404 } + h['no_role'] = { code: 404 } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + + context 'when organization is suspended' do + let(:expected_codes_and_responses) do + h = super() + h['space_developer'] = { code: 403, errors: CF_ORG_SUSPENDED } + h + end + + before do + org.update(status: VCAP::CloudController::Organization::SUSPENDED) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + end + + context 'telemetry' do + before do + space.organization.add_user(user) + space.add_developer(user) + end + + it 'logs the required fields when the app gets updated' do + Timecop.freeze do + expected_json = { + 'telemetry-source' => 'cloud_controller_ng', + 'telemetry-time' => Time.now.to_datetime.rfc3339, + 'update-app' => { + 'api-version' => 'v3', + 'app-id' => OpenSSL::Digest::SHA256.hexdigest(app_model.guid), + 'user-id' => OpenSSL::Digest::SHA256.hexdigest(user.guid) + } + } + expect_any_instance_of(ActiveSupport::Logger).to receive(:info).with(Oj.dump(expected_json)) + + patch "/v3/apps/#{app_model.guid}", update_request.to_json, user_header + expect(last_response.status).to eq(200), last_response.body + end + end + end + end +end diff --git a/spec/request/apps/droplet_spec.rb b/spec/request/apps/droplet_spec.rb new file mode 100644 index 00000000000..8983ff34be3 --- /dev/null +++ b/spec/request/apps/droplet_spec.rb @@ -0,0 +1,329 @@ +require 'spec_helper' +require 'actions/process_create_from_app_droplet' +require 'request_spec_shared_examples' +require_relative 'shared_context' + +# Split from spec/request/apps_spec.rb for better test parallelization + +RSpec.describe 'Apps' do + include_context 'apps request spec' + + describe 'GET /v3/apps/:guid/relationships/current_droplet' do + let(:api_call) { ->(user_headers) { get "/v3/apps/#{droplet_model.app_guid}/relationships/current_droplet", nil, user_headers } } + let(:app_model) { VCAP::CloudController::AppModel.make(space_guid: space.guid) } + let!(:droplet_model) { VCAP::CloudController::DropletModel.make(app_guid: app_model.guid) } + let(:expected_response) do + { + 'data' => { + 'guid' => droplet_model.guid + }, + 'links' => { + 'self' => { 'href' => "#{link_prefix}/v3/apps/#{droplet_model.app_guid}/relationships/current_droplet" }, + 'related' => { 'href' => "#{link_prefix}/v3/apps/#{droplet_model.app_guid}/droplets/current" } + } + } + end + + let(:expected_codes_and_responses) do + h = Hash.new({ code: 200, response_object: expected_response }.freeze) + h['no_role'] = { code: 404 } + h['org_billing_manager'] = { code: 404 } + h['org_auditor'] = { code: 404 } + h + end + + before do + app_model.droplet_guid = droplet_model.guid + app_model.save + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + + describe 'GET /v3/apps/:guid/droplets/current' do + let(:api_call) { ->(user_headers) { get "/v3/apps/#{droplet_model.app_guid}/droplets/current", nil, user_headers } } + let(:app_model) { VCAP::CloudController::AppModel.make(space_guid: space.guid) } + let(:package_model) { VCAP::CloudController::PackageModel.make(app_guid: app_model.guid) } + let!(:droplet_model) do + VCAP::CloudController::DropletModel.make( + app_guid: app_model.guid, + package_guid: package_model.guid, + buildpack_receipt_buildpack: 'http://buildpack.git.url.com', + error_description: 'example error', + execution_metadata: 'some-data', + droplet_hash: 'shalalala', + sha256_checksum: 'droplet-sha256-checksum', + process_types: { 'web' => 'start-command' } + ) + end + let(:expected_response) do + { + 'guid' => droplet_model.guid, + 'state' => VCAP::CloudController::DropletModel::STAGED_STATE, + 'error' => 'example error', + 'lifecycle' => { + 'type' => 'buildpack', + 'data' => {} + }, + 'checksum' => { 'type' => 'sha256', 'value' => 'droplet-sha256-checksum' }, + 'buildpacks' => [{ 'name' => 'http://buildpack.git.url.com', 'detect_output' => nil, 'buildpack_name' => nil, 'version' => nil }], + 'stack' => 'stack-name', + 'execution_metadata' => 'some-data', + 'process_types' => { 'web' => 'start-command' }, + 'image' => nil, + 'created_at' => iso8601, + 'updated_at' => iso8601, + 'relationships' => { 'app' => { 'data' => { 'guid' => app_model.guid } } }, + 'links' => { + 'self' => { 'href' => "#{link_prefix}/v3/droplets/#{droplet_model.guid}" }, + 'package' => { 'href' => "#{link_prefix}/v3/packages/#{package_model.guid}" }, + 'app' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}" }, + 'download' => { 'href' => "#{link_prefix}/v3/droplets/#{droplet_model.guid}/download" }, + 'assign_current_droplet' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/relationships/current_droplet", 'method' => 'PATCH' } + }, + 'metadata' => { + 'labels' => {}, + 'annotations' => {} + } + } + end + let(:expected_codes_and_responses) do + h = Hash.new({ code: 200, response_object: expected_response }.freeze) + h['no_role'] = { code: 404 } + h['org_billing_manager'] = { code: 404 } + h['org_auditor'] = { code: 404 } + h + end + + before do + droplet_model.buildpack_lifecycle_data.update(buildpacks: ['http://buildpack.git.url.com'], stack: 'stack-name') + app_model.droplet_guid = droplet_model.guid + app_model.save + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + + describe 'PATCH /v3/apps/:guid/relationships/current_droplet' do + let(:stack) { VCAP::CloudController::Stack.make(name: 'stack-name') } + let(:app_model) do + VCAP::CloudController::AppModel.make( + :buildpack, + name: 'my_app', + space: space, + desired_state: 'STOPPED' + ) + end + let(:droplet) do + VCAP::CloudController::DropletModel.make( + :docker, + app: app_model, + process_types: { web: 'rackup' }, + state: VCAP::CloudController::DropletModel::STAGED_STATE, + package: VCAP::CloudController::PackageModel.make + ) + end + let(:request_body) { { data: { guid: droplet.guid } } } + + before do + app_model.lifecycle_data.buildpacks = ['http://example.com/git'] + app_model.lifecycle_data.stack = stack.name + app_model.lifecycle_data.save + end + + context 'assigning the current droplet of the app' do + let(:api_call) { ->(user_headers) { patch "/v3/apps/#{app_model.guid}/relationships/current_droplet", request_body.to_json, user_headers } } + let(:current_droplet_response_object) do + { + 'data' => { + 'guid' => droplet.guid + }, + 'links' => { + 'self' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/relationships/current_droplet" }, + 'related' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/droplets/current" } + } + } + end + + let(:expected_codes_and_responses) do + h = Hash.new({ code: 403, errors: CF_NOT_AUTHORIZED }.freeze) + h['no_role'] = { code: 404 } + h['org_auditor'] = { code: 404 } + h['org_billing_manager'] = { code: 404 } + h['admin'] = { + code: 200, + response_object: current_droplet_response_object + } + h['space_supporter'] = { + code: 200, + response_object: current_droplet_response_object + } + h['space_developer'] = { + code: 200, + response_object: current_droplet_response_object + } + h + end + + before do + space.organization.add_user(user) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + + context 'when organization is suspended' do + let(:expected_codes_and_responses) do + h = super() + %w[space_supporter space_developer].each { |r| h[r] = { code: 403, errors: CF_ORG_SUSPENDED } } + h + end + + before do + org.update(status: VCAP::CloudController::Organization::SUSPENDED) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + end + + context 'events' do + before do + space.organization.add_user(user) + space.add_developer(user) + end + + it 'creates audit.app.droplet.mapped event' do + patch "/v3/apps/#{app_model.guid}/relationships/current_droplet", request_body.to_json, user_header + + events = VCAP::CloudController::Event.where(actor: user.guid).all + + droplet_event = events.find { |e| e.type == 'audit.app.droplet.mapped' } + expect(droplet_event.values).to include({ + type: 'audit.app.droplet.mapped', + actee: app_model.guid, + actee_type: 'app', + actee_name: 'my_app', + actor: user.guid, + actor_type: 'user', + actor_name: user_email, + actor_username: user_name, + space_guid: space.guid, + organization_guid: space.organization.guid + }) + expect(droplet_event.metadata).to eq({ 'request' => { 'droplet_guid' => droplet.guid } }) + + expect(app_model.reload.processes.count).to eq(1) + end + + context 'with two process types' do + let(:droplet) do + VCAP::CloudController::DropletModel.make( + app: app_model, + process_types: { web: 'rackup', other: 'cron' }, + state: VCAP::CloudController::DropletModel::STAGED_STATE + ) + end + + it 'creates audit.app.process.create events for each process' do + patch "/v3/apps/#{app_model.guid}/relationships/current_droplet", request_body.to_json, user_header + + expect(last_response.status).to eq(200) + + events = VCAP::CloudController::Event.where(actor: user.guid).all + + expect(app_model.reload.processes.count).to eq(2) + web_process = app_model.processes.find { |i| i.type == 'web' } + other_process = app_model.processes.find { |i| i.type == 'other' } + expect(web_process).to be_present + expect(other_process).to be_present + + web_process_event = events.find { |e| e.metadata['process_guid'] == web_process.guid } + expect(web_process_event.values).to include({ + type: 'audit.app.process.create', + actee: app_model.guid, + actee_type: 'app', + actee_name: 'my_app', + actor: user.guid, + actor_type: 'user', + actor_name: user_email, + actor_username: user_name, + space_guid: space.guid, + organization_guid: space.organization.guid + }) + expect(web_process_event.metadata).to eq({ 'process_guid' => web_process.guid, 'process_type' => 'web' }) + + other_process_event = events.find { |e| e.metadata['process_guid'] == other_process.guid } + expect(other_process_event.values).to include({ + type: 'audit.app.process.create', + actee: app_model.guid, + actee_type: 'app', + actee_name: 'my_app', + actor: user.guid, + actor_type: 'user', + actor_name: user_email, + actor_username: user_name, + space_guid: space.guid, + organization_guid: space.organization.guid + }) + expect(other_process_event.metadata).to eq({ 'process_guid' => other_process.guid, 'process_type' => 'other' }) + end + end + end + + context 'sidecars' do + let(:droplet) do + VCAP::CloudController::DropletModel.make( + :docker, + app: app_model, + process_types: { web: 'rackup' }, + state: VCAP::CloudController::DropletModel::STAGED_STATE, + package: VCAP::CloudController::PackageModel.make, + sidecars: + [ + { + name: 'sidecar_one', + command: 'bundle exec rackup', + process_types: ['web'], + memory: 300 + } + ] + ) + end + + before do + space.organization.add_user(user) + space.add_developer(user) + end + + it 'creates sidecars that were saved on the droplet' do + patch "/v3/apps/#{app_model.guid}/relationships/current_droplet", request_body.to_json, user_header + + expect(last_response.status).to eq(200) + + expect(app_model.reload.processes.count).to eq(1) + expect(app_model.reload.sidecars.count).to eq(1) + end + + it 'logs the create-sidecar event' do + Timecop.freeze do + expected_json = { + 'telemetry-source' => 'cloud_controller_ng', + 'telemetry-time' => Time.now.to_datetime.rfc3339, + 'create-sidecar' => { + 'api-version' => 'v3', + 'origin' => 'buildpack', + 'memory-in-mb' => 300, + 'process-types' => ['web'], + 'app-id' => OpenSSL::Digest::SHA256.hexdigest(app_model.guid) + } + } + expect_any_instance_of(ActiveSupport::Logger).to receive(:info).with(Oj.dump(expected_json)) + + patch "/v3/apps/#{app_model.guid}/relationships/current_droplet", request_body.to_json, user_header + + expect(last_response.status).to eq(200), last_response.body + end + end + end + end +end diff --git a/spec/request/apps/environment_spec.rb b/spec/request/apps/environment_spec.rb new file mode 100644 index 00000000000..6d6527a8027 --- /dev/null +++ b/spec/request/apps/environment_spec.rb @@ -0,0 +1,178 @@ +require 'spec_helper' +require 'actions/process_create_from_app_droplet' +require 'request_spec_shared_examples' +require_relative 'shared_context' + +# Split from spec/request/apps_spec.rb for better test parallelization + +RSpec.describe 'Apps' do + include_context 'apps request spec' + + describe 'PATCH /v3/apps/:guid/environment_variables' do + before do + space.organization.add_user(user) + end + + let(:update_request) do + { + var: { + override: 'new-value', + new_key: 'brand-new-value' + } + } + end + let(:app_model) do + VCAP::CloudController::AppModel.make( + name: 'name1', + space: space, + desired_state: 'STOPPED', + environment_variables: { + override: 'original', + preserve: 'keep' + } + ) + end + let(:api_call) { ->(user_headers) { patch "/v3/apps/#{app_model.guid}/environment_variables", update_request.to_json, user_headers } } + let(:app_model_response_object) do + { + 'var' => { + 'override' => 'new-value', + 'new_key' => 'brand-new-value', + 'preserve' => 'keep' + }, + 'links' => { + 'self' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/environment_variables" }, + 'app' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}" } + } + } + end + let(:expected_codes_and_responses) do + h = Hash.new({ code: 404 }.freeze) + %w[global_auditor admin_read_only org_manager space_auditor space_manager].each do |r| + h[r] = { code: 403, errors: CF_NOT_AUTHORIZED } + end + h['admin'] = h['space_developer'] = h['space_supporter'] = { + code: 200, + response_object: app_model_response_object + } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + + context 'when organization is suspended' do + let(:expected_codes_and_responses) do + h = super() + %w[space_developer space_supporter].each { |r| h[r] = { code: 403, errors: CF_ORG_SUSPENDED } } + h + end + + before do + org.update(status: VCAP::CloudController::Organization::SUSPENDED) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + end + + describe 'GET /v3/apps/:guid/environment_variables' do + let(:app_model) { VCAP::CloudController::AppModel.make(name: 'my_app', space: space, desired_state: 'STARTED', environment_variables: { meep: 'moop' }) } + let(:api_call) { ->(user_headers) { get "/v3/apps/#{app_model.guid}/environment_variables", nil, user_headers } } + let(:app_model_response_object) do + { + var: { + meep: 'moop' + }, + links: { + self: { href: "#{link_prefix}/v3/apps/#{app_model.guid}/environment_variables" }, + app: { href: "#{link_prefix}/v3/apps/#{app_model.guid}" } + } + } + end + + before do + space.organization.add_user(user) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS do + let(:expected_codes_and_responses) do + h = Hash.new({ code: 404 }.freeze) + h['global_auditor'] = h['org_manager'] = h['space_auditor'] = h['space_manager'] = { code: 403 } + h['admin'] = h['admin_read_only'] = h['space_developer'] = h['space_supporter'] = { + code: 200, + response_object: app_model_response_object + } + h + 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_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS do + let(:expected_codes_and_responses) do + h = Hash.new({ code: 404 }.freeze) + h['global_auditor'] = h['org_manager'] = h['space_auditor'] = h['space_manager'] = h['space_developer'] = h['space_supporter'] = { code: 403 } + h['admin'] = h['admin_read_only'] = { + code: 200, + response_object: app_model_response_object + } + h + end + end + end + + context 'when the encryption_key_label is invalid' do + before do + allow_any_instance_of(ErrorPresenter).to receive(:raise_500?).and_return(false) + end + + it 'fails to decrypt the environment variables and returns a 500 error' do + app_model # ensure that app model is created before run_cipher is mocked to throw an error + allow(VCAP::CloudController::Encryptor).to receive(:run_cipher).and_raise(VCAP::CloudController::Encryptor::EncryptorError) + api_call.call(admin_headers) + + expect(last_response).to have_status_code(500) + expect(parsed_response['errors'].first['detail']).to match(/Error while processing encrypted data/i) + end + end + end + + describe 'GET /v3/apps/:guid/permissions' do + let(:org) { VCAP::CloudController::Organization.make } + let(:space) { VCAP::CloudController::Space.make(organization: org) } + let(:app_model) { VCAP::CloudController::AppModel.make(name: 'name1', space: space, desired_state: 'STOPPED') } + let(:api_call) { ->(user_headers) { get "/v3/apps/#{app_model.guid}/permissions", nil, user_headers } } + + let(:read_all_response) do + { + read_basic_data: true, + read_sensitive_data: true + } + end + + let(:read_basic_response) do + { + read_basic_data: true, + read_sensitive_data: false + } + end + + let(:expected_codes_and_responses) do + h = Hash.new({ code: 404 }.freeze) + h['admin'] = { code: 200, response_object: read_all_response } + h['admin_read_only'] = { code: 200, response_object: read_all_response } + h['global_auditor'] = { code: 200, response_object: read_basic_response } + h['org_manager'] = { code: 200, response_object: read_basic_response } + h['space_manager'] = { code: 200, response_object: read_basic_response } + h['space_auditor'] = { code: 200, response_object: read_basic_response } + h['space_developer'] = { code: 200, response_object: read_all_response } + h['space_supporter'] = { code: 200, response_object: read_basic_response } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end +end diff --git a/spec/request/apps/list_spec.rb b/spec/request/apps/list_spec.rb new file mode 100644 index 00000000000..dc20f00fe14 --- /dev/null +++ b/spec/request/apps/list_spec.rb @@ -0,0 +1,940 @@ +require 'spec_helper' +require 'actions/process_create_from_app_droplet' +require 'request_spec_shared_examples' +require_relative 'shared_context' + +# Split from spec/request/apps_spec.rb for better test parallelization + +RSpec.describe 'Apps' do + include_context 'apps request spec' + + describe 'GET /v3/apps' do + before do + space.organization.add_user(user) + end + + context 'listing all apps' do + let(:api_call) { ->(user_headers) { get '/v3/apps', nil, user_headers } } + let(:space2) { VCAP::CloudController::Space.make(organization: org) } + let(:buildpack_lifecycle) { VCAP::CloudController::BuildpackLifecycleDataModel.make(stack: 'cool-stack', app: app_model1) } + let(:app_model1) { VCAP::CloudController::AppModel.make(guid: 'app1_guid', name: 'name1', space: space) } + let(:app_model2) { VCAP::CloudController::AppModel.make(guid: 'app2_guid', name: 'name2', space: space2) } + + let(:app_model1_response_object) do + { + guid: app_model1.guid, + created_at: iso8601, + updated_at: iso8601, + name: app_model1.name, + state: 'STOPPED', + lifecycle: { + type: 'buildpack', + data: { buildpacks: [], stack: app_model1.lifecycle_data.stack } + }, + relationships: { + space: { data: { guid: space.guid } }, + current_droplet: { data: { guid: nil } } + }, + metadata: { + labels: {}, + annotations: {} + }, + links: { + self: { href: "#{link_prefix}/v3/apps/app1_guid" }, + environment_variables: { href: "#{link_prefix}/v3/apps/app1_guid/environment_variables" }, + space: { href: "#{link_prefix}/v3/spaces/#{space.guid}" }, + processes: { href: "#{link_prefix}/v3/apps/app1_guid/processes" }, + packages: { href: "#{link_prefix}/v3/apps/app1_guid/packages" }, + current_droplet: { href: "#{link_prefix}/v3/apps/app1_guid/droplets/current" }, + droplets: { href: "#{link_prefix}/v3/apps/app1_guid/droplets" }, + tasks: { href: "#{link_prefix}/v3/apps/app1_guid/tasks" }, + start: { href: "#{link_prefix}/v3/apps/app1_guid/actions/start", method: 'POST' }, + stop: { href: "#{link_prefix}/v3/apps/app1_guid/actions/stop", method: 'POST' }, + clear_buildpack_cache: { href: "#{link_prefix}/v3/apps/app1_guid/actions/clear_buildpack_cache", method: 'POST' }, + revisions: { href: "#{link_prefix}/v3/apps/app1_guid/revisions" }, + deployed_revisions: { href: "#{link_prefix}/v3/apps/app1_guid/revisions/deployed" }, + features: { href: "#{link_prefix}/v3/apps/app1_guid/features" } + } + } + end + + let(:app_model2_response_object) do + { + guid: app_model2.guid, + created_at: iso8601, + updated_at: iso8601, + name: app_model2.name, + state: 'STOPPED', + lifecycle: { + type: 'buildpack', + data: { buildpacks: [], stack: app_model2.lifecycle_data.stack } + }, + relationships: { + space: { data: { guid: space2.guid } }, + current_droplet: { data: { guid: nil } } + }, + metadata: { + labels: {}, + annotations: {} + }, + links: { + self: { href: "#{link_prefix}/v3/apps/app2_guid" }, + environment_variables: { href: "#{link_prefix}/v3/apps/app2_guid/environment_variables" }, + space: { href: "#{link_prefix}/v3/spaces/#{space2.guid}" }, + processes: { href: "#{link_prefix}/v3/apps/app2_guid/processes" }, + packages: { href: "#{link_prefix}/v3/apps/app2_guid/packages" }, + current_droplet: { href: "#{link_prefix}/v3/apps/app2_guid/droplets/current" }, + droplets: { href: "#{link_prefix}/v3/apps/app2_guid/droplets" }, + tasks: { href: "#{link_prefix}/v3/apps/app2_guid/tasks" }, + start: { href: "#{link_prefix}/v3/apps/app2_guid/actions/start", method: 'POST' }, + stop: { href: "#{link_prefix}/v3/apps/app2_guid/actions/stop", method: 'POST' }, + clear_buildpack_cache: { href: "#{link_prefix}/v3/apps/app2_guid/actions/clear_buildpack_cache", method: 'POST' }, + revisions: { href: "#{link_prefix}/v3/apps/app2_guid/revisions" }, + deployed_revisions: { href: "#{link_prefix}/v3/apps/app2_guid/revisions/deployed" }, + features: { href: "#{link_prefix}/v3/apps/app2_guid/features" } + } + } + end + + let(:expected_codes_and_responses) do + h = Hash.new({ code: 200, response_objects: [app_model1_response_object, app_model2_response_object] }.freeze) + + h['org_auditor'] = { + code: 200, + response_objects: [] + } + + h['org_billing_manager'] = { + code: 200, + response_objects: [] + } + + h['space_manager'] = { + code: 200, + response_objects: [ + app_model1_response_object + ] + } + + h['space_auditor'] = { + code: 200, + response_objects: [ + app_model1_response_object + ] + } + + h['space_developer'] = { + code: 200, + response_objects: [ + app_model1_response_object + ] + } + + h['space_supporter'] = { + code: 200, + response_objects: [ + app_model1_response_object + ] + } + + h['no_role'] = { code: 200, response_objects: [] } + h + end + + it_behaves_like 'permissions for list endpoint', ALL_PERMISSIONS + end + + describe 'query list parameters' do + it_behaves_like 'list query endpoint' do + let(:request) { 'v3/apps' } + + let(:message) { VCAP::CloudController::AppsListMessage } + + let(:params) do + { + page: '2', + per_page: '10', + order_by: 'updated_at', + names: 'foo', + guids: 'foo', + organization_guids: 'foo', + space_guids: 'foo', + stacks: 'cf', + include: 'space', + lifecycle_type: 'buildpack', + label_selector: 'foo,bar', + created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", + updated_ats: { gt: Time.now.utc.iso8601 } + } + end + + let!(:app_model) { VCAP::CloudController::AppModel.make } + end + end + + context 'pagination' do + before do + space.add_developer(user) + end + + it 'returns a paginated list of apps the user has access to' do + buildpack = VCAP::CloudController::Buildpack.make(name: 'bp-name') + stack = VCAP::CloudController::Stack.make(name: 'stack-name') + + app_model1 = VCAP::CloudController::AppModel.make(name: 'name1', space: space, desired_state: 'STOPPED') + app_model1.lifecycle_data.update( + buildpacks: [buildpack.name], + stack: stack.name + ) + + app_model2 = VCAP::CloudController::AppModel.make( + :docker, + name: 'name2', + space: space, + desired_state: 'STARTED' + ) + VCAP::CloudController::AppModel.make(space:) + VCAP::CloudController::AppModel.make + + get '/v3/apps?per_page=2&include=space', nil, user_header + expect(last_response.status).to eq(200) + + parsed_response = Oj.load(last_response.body) + expect(parsed_response).to be_a_response_like( + { + 'pagination' => { + 'total_results' => 3, + 'total_pages' => 2, + 'first' => { 'href' => "#{link_prefix}/v3/apps?include=space&page=1&per_page=2" }, + 'last' => { 'href' => "#{link_prefix}/v3/apps?include=space&page=2&per_page=2" }, + 'next' => { 'href' => "#{link_prefix}/v3/apps?include=space&page=2&per_page=2" }, + 'previous' => nil + }, + 'resources' => [ + { + 'guid' => app_model1.guid, + 'name' => 'name1', + 'state' => 'STOPPED', + 'lifecycle' => { + 'type' => 'buildpack', + 'data' => { + 'buildpacks' => ['bp-name'], + 'stack' => 'stack-name' + } + }, + 'relationships' => { + 'space' => { + 'data' => { + 'guid' => space.guid + } + }, + 'current_droplet' => { + 'data' => { + 'guid' => nil + } + } + }, + 'created_at' => iso8601, + 'updated_at' => iso8601, + 'metadata' => { 'labels' => {}, 'annotations' => {} }, + 'links' => { + 'self' => { 'href' => "#{link_prefix}/v3/apps/#{app_model1.guid}" }, + 'processes' => { 'href' => "#{link_prefix}/v3/apps/#{app_model1.guid}/processes" }, + 'packages' => { 'href' => "#{link_prefix}/v3/apps/#{app_model1.guid}/packages" }, + 'environment_variables' => { 'href' => "#{link_prefix}/v3/apps/#{app_model1.guid}/environment_variables" }, + 'space' => { 'href' => "#{link_prefix}/v3/spaces/#{space.guid}" }, + 'current_droplet' => { 'href' => "#{link_prefix}/v3/apps/#{app_model1.guid}/droplets/current" }, + 'droplets' => { 'href' => "#{link_prefix}/v3/apps/#{app_model1.guid}/droplets" }, + 'tasks' => { 'href' => "#{link_prefix}/v3/apps/#{app_model1.guid}/tasks" }, + 'start' => { 'href' => "#{link_prefix}/v3/apps/#{app_model1.guid}/actions/start", 'method' => 'POST' }, + 'stop' => { 'href' => "#{link_prefix}/v3/apps/#{app_model1.guid}/actions/stop", 'method' => 'POST' }, + 'clear_buildpack_cache' => { 'href' => "#{link_prefix}/v3/apps/#{app_model1.guid}/actions/clear_buildpack_cache", 'method' => 'POST' }, + 'revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model1.guid}/revisions" }, + 'deployed_revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model1.guid}/revisions/deployed" }, + 'features' => { 'href' => "#{link_prefix}/v3/apps/#{app_model1.guid}/features" } + } + }, + { + 'guid' => app_model2.guid, + 'name' => 'name2', + 'state' => 'STARTED', + 'lifecycle' => { + 'type' => 'docker', + 'data' => {} + }, + 'relationships' => { + 'space' => { + 'data' => { + 'guid' => space.guid + } + }, + 'current_droplet' => { + 'data' => { + 'guid' => nil + } + } + }, + 'created_at' => iso8601, + 'updated_at' => iso8601, + 'metadata' => { 'labels' => {}, 'annotations' => {} }, + 'links' => { + 'self' => { 'href' => "#{link_prefix}/v3/apps/#{app_model2.guid}" }, + 'processes' => { 'href' => "#{link_prefix}/v3/apps/#{app_model2.guid}/processes" }, + 'packages' => { 'href' => "#{link_prefix}/v3/apps/#{app_model2.guid}/packages" }, + 'environment_variables' => { 'href' => "#{link_prefix}/v3/apps/#{app_model2.guid}/environment_variables" }, + 'space' => { 'href' => "#{link_prefix}/v3/spaces/#{space.guid}" }, + 'current_droplet' => { 'href' => "#{link_prefix}/v3/apps/#{app_model2.guid}/droplets/current" }, + 'droplets' => { 'href' => "#{link_prefix}/v3/apps/#{app_model2.guid}/droplets" }, + 'tasks' => { 'href' => "#{link_prefix}/v3/apps/#{app_model2.guid}/tasks" }, + 'start' => { 'href' => "#{link_prefix}/v3/apps/#{app_model2.guid}/actions/start", 'method' => 'POST' }, + 'stop' => { 'href' => "#{link_prefix}/v3/apps/#{app_model2.guid}/actions/stop", 'method' => 'POST' }, + 'clear_buildpack_cache' => { 'href' => "#{link_prefix}/v3/apps/#{app_model2.guid}/actions/clear_buildpack_cache", 'method' => 'POST' }, + 'revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model2.guid}/revisions" }, + 'deployed_revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model2.guid}/revisions/deployed" }, + 'features' => { 'href' => "#{link_prefix}/v3/apps/#{app_model2.guid}/features" } + } + } + ], + 'included' => { + 'spaces' => [{ + 'guid' => space.guid, + 'created_at' => iso8601, + 'updated_at' => iso8601, + 'name' => space.name, + 'relationships' => { + 'organization' => { + 'data' => { + 'guid' => space.organization.guid + } + }, + 'quota' => { + 'data' => nil + } + }, + 'metadata' => { + 'labels' => {}, + 'annotations' => {} + }, + 'links' => { + 'self' => { + 'href' => "#{link_prefix}/v3/spaces/#{space.guid}" + }, + 'organization' => { + 'href' => "#{link_prefix}/v3/organizations/#{space.organization.guid}" + }, + 'features' => { 'href' => %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}/features} }, + 'apply_manifest' => { + 'href' => "#{link_prefix}/v3/spaces/#{space.guid}/actions/apply_manifest", + 'method' => 'POST' + } + } + }] + } + } + ) + end + end + + context 'filtering by timestamps' do + before do + VCAP::CloudController::AppModel.plugin :timestamps, update_on_create: false + end + + # .make updates the resource after creating it, over writing our passed in updated_at timestamp + # Therefore we cannot use shared_examples as the updated_at will not be as written + let!(:resource_1) { VCAP::CloudController::AppModel.create(name: '1', created_at: '2020-05-26T18:47:01Z', updated_at: '2020-05-26T18:47:01Z', space: space) } + let!(:resource_2) { VCAP::CloudController::AppModel.create(name: '2', created_at: '2020-05-26T18:47:02Z', updated_at: '2020-05-26T18:47:02Z', space: space) } + let!(:resource_3) { VCAP::CloudController::AppModel.create(name: '3', created_at: '2020-05-26T18:47:03Z', updated_at: '2020-05-26T18:47:03Z', space: space) } + let!(:resource_4) { VCAP::CloudController::AppModel.create(name: '4', created_at: '2020-05-26T18:47:04Z', updated_at: '2020-05-26T18:47:04Z', space: space) } + + after do + VCAP::CloudController::AppModel.plugin :timestamps, update_on_create: true + end + + it 'filters by the created at' do + get "/v3/apps?created_ats[lt]=#{resource_3.created_at.iso8601}", nil, admin_header + + expect(last_response).to have_status_code(200) + expect(parsed_response['resources'].pluck('guid')).to contain_exactly(resource_1.guid, resource_2.guid) + end + + it 'filters ny the updated_at' do + get "/v3/apps?updated_ats[lt]=#{resource_3.updated_at.iso8601}", nil, admin_header + + expect(last_response).to have_status_code(200) + expect(parsed_response['resources'].pluck('guid')).to contain_exactly(resource_1.guid, resource_2.guid) + end + end + + context 'faceted search' do + let(:admin_header) { headers_for(user, scopes: %w[cloud_controller.admin]) } + + it 'filters by guids' do + app_model1 = VCAP::CloudController::AppModel.make(name: 'name1') + VCAP::CloudController::AppModel.make(name: 'name2') + app_model3 = VCAP::CloudController::AppModel.make(name: 'name3') + + get "/v3/apps?guids=#{app_model1.guid}%2C#{app_model3.guid}", nil, admin_header + + expected_pagination = { + 'total_results' => 2, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/apps?guids=#{app_model1.guid}%2C#{app_model3.guid}&page=1&per_page=50" }, + 'last' => { 'href' => "#{link_prefix}/v3/apps?guids=#{app_model1.guid}%2C#{app_model3.guid}&page=1&per_page=50" }, + 'next' => nil, + 'previous' => nil + } + + parsed_response = Oj.load(last_response.body) + + expect(last_response.status).to eq(200) + expect(parsed_response['resources'].pluck('name')).to eq(%w[name1 name3]) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + + it 'filters by names' do + VCAP::CloudController::AppModel.make(name: 'name1') + VCAP::CloudController::AppModel.make(name: 'name2') + VCAP::CloudController::AppModel.make(name: 'name3') + + get '/v3/apps?names=name1%2Cname2', nil, admin_header + + expected_pagination = { + 'total_results' => 2, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/apps?names=name1%2Cname2&page=1&per_page=50" }, + 'last' => { 'href' => "#{link_prefix}/v3/apps?names=name1%2Cname2&page=1&per_page=50" }, + 'next' => nil, + 'previous' => nil + } + + parsed_response = Oj.load(last_response.body) + + expect(last_response.status).to eq(200) + expect(parsed_response['resources'].pluck('name')).to eq(%w[name1 name2]) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + + it 'filters by organizations' do + app_model1 = VCAP::CloudController::AppModel.make(name: 'name1') + VCAP::CloudController::AppModel.make(name: 'name2') + app_model3 = VCAP::CloudController::AppModel.make(name: 'name3') + + get "/v3/apps?organization_guids=#{app_model1.organization.guid}%2C#{app_model3.organization.guid}", nil, admin_header + + expected_pagination = { + 'total_results' => 2, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/apps?organization_guids=#{app_model1.organization.guid}%2C#{app_model3.organization.guid}&page=1&per_page=50" }, + 'last' => { 'href' => "#{link_prefix}/v3/apps?organization_guids=#{app_model1.organization.guid}%2C#{app_model3.organization.guid}&page=1&per_page=50" }, + 'next' => nil, + 'previous' => nil + } + + parsed_response = Oj.load(last_response.body) + + expect(last_response.status).to eq(200) + expect(parsed_response['resources'].pluck('name')).to eq(%w[name1 name3]) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + + it 'filters by spaces' do + app_model1 = VCAP::CloudController::AppModel.make(name: 'name1') + VCAP::CloudController::AppModel.make(name: 'name2') + app_model3 = VCAP::CloudController::AppModel.make(name: 'name3') + + get "/v3/apps?space_guids=#{app_model1.space.guid}%2C#{app_model3.space.guid}", nil, admin_header + + expected_pagination = { + 'total_results' => 2, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/apps?page=1&per_page=50&space_guids=#{app_model1.space.guid}%2C#{app_model3.space.guid}" }, + 'last' => { 'href' => "#{link_prefix}/v3/apps?page=1&per_page=50&space_guids=#{app_model1.space.guid}%2C#{app_model3.space.guid}" }, + 'next' => nil, + 'previous' => nil + } + + parsed_response = Oj.load(last_response.body) + + expect(last_response.status).to eq(200) + expect(parsed_response['resources'].pluck('name')).to eq(%w[name1 name3]) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + + it 'filters by stack names' do + app_model1 = VCAP::CloudController::AppModel.make(name: 'name1') + app_model2 = VCAP::CloudController::AppModel.make(name: 'name2') + app_model3 = VCAP::CloudController::AppModel.make(name: 'name3') + + stack2 = VCAP::CloudController::Stack.make(name: 'name2') + stack3 = VCAP::CloudController::Stack.make(name: 'name3') + + app_model1.lifecycle_data.stack = stack2.name + app_model1.lifecycle_data.save + + app_model2.lifecycle_data.stack = stack2.name + app_model2.lifecycle_data.save + + app_model3.lifecycle_data.stack = stack3.name + app_model3.lifecycle_data.save + + get "/v3/apps?stacks=#{stack2.name}", nil, admin_header + + expected_pagination = { + 'total_results' => 2, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/apps?page=1&per_page=50&stacks=#{stack2.name}" }, + 'last' => { 'href' => "#{link_prefix}/v3/apps?page=1&per_page=50&stacks=#{stack2.name}" }, + 'next' => nil, + 'previous' => nil + } + + parsed_response = Oj.load(last_response.body) + + expect(last_response.status).to eq(200) + expect(parsed_response['resources'].pluck('name')).to eq(%w[name1 name2]) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + + it 'filters by null stacks' do + app_model1 = VCAP::CloudController::AppModel.make(name: 'name1') + app_model2 = VCAP::CloudController::AppModel.make(name: 'name2') + app_model3 = VCAP::CloudController::AppModel.make(name: 'name3') + + stack2 = VCAP::CloudController::Stack.make(name: 'name2') + stack3 = VCAP::CloudController::Stack.make(name: 'name3') + + app_model1.lifecycle_data.stack = nil + app_model1.lifecycle_data.save + + app_model2.lifecycle_data.stack = stack2.name + app_model2.lifecycle_data.save + + app_model3.lifecycle_data.stack = stack3.name + app_model3.lifecycle_data.save + + get '/v3/apps?stacks=', nil, admin_header + + expected_pagination = { + 'total_results' => 1, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/apps?page=1&per_page=50&stacks=" }, + 'last' => { 'href' => "#{link_prefix}/v3/apps?page=1&per_page=50&stacks=" }, + 'next' => nil, + 'previous' => nil + } + + parsed_response = Oj.load(last_response.body) + + expect(last_response.status).to eq(200) + expect(parsed_response['resources'].pluck('name')).to eq(['name1']) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + + it 'filters by lifecycle_type' do + VCAP::CloudController::AppModel.make(name: 'name1') + docker_app_model = VCAP::CloudController::AppModel.make(name: 'name2') + VCAP::CloudController::AppModel.make(name: 'name3') + + docker_app_model.buildpack_lifecycle_data = nil + docker_app_model.save + + get '/v3/apps?lifecycle_type=buildpack', nil, admin_header + + expected_pagination = { + 'total_results' => 2, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/apps?lifecycle_type=buildpack&page=1&per_page=50" }, + 'last' => { 'href' => "#{link_prefix}/v3/apps?lifecycle_type=buildpack&page=1&per_page=50" }, + 'next' => nil, + 'previous' => nil + } + + parsed_response = Oj.load(last_response.body) + + expect(last_response.status).to eq(200) + expect(parsed_response['resources'].pluck('name')).to eq(%w[name1 name3]) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + end + + context 'ordering' do + before do + space.add_developer(user) + end + + it 'can order by name' do + VCAP::CloudController::AppModel.make(space: space, name: 'zed') + VCAP::CloudController::AppModel.make(space: space, name: 'alpha') + VCAP::CloudController::AppModel.make(space: space, name: 'gamma') + VCAP::CloudController::AppModel.make(space: space, name: 'delta') + VCAP::CloudController::AppModel.make(space: space, name: 'theta') + + ascending = %w[alpha delta gamma theta zed] + descending = ascending.reverse + + # ASCENDING + get '/v3/apps?order_by=name', nil, user_header + expect(last_response.status).to eq(200) + parsed_response = Oj.load(last_response.body) + app_names = parsed_response['resources'].pluck('name') + expect(app_names).to eq(ascending) + expect(parsed_response['pagination']['first']['href']).to include("order_by=#{CGI.escape('+')}name") + + # DESCENDING + get '/v3/apps?order_by=-name', nil, user_header + expect(last_response.status).to eq(200) + parsed_response = Oj.load(last_response.body) + app_names = parsed_response['resources'].pluck('name') + expect(app_names).to eq(descending) + expect(parsed_response['pagination']['first']['href']).to include('order_by=-name') + end + + it 'can order by state' do + VCAP::CloudController::AppModel.make(space: space, desired_state: 'STARTED') + VCAP::CloudController::AppModel.make(space: space, desired_state: 'STOPPED') + VCAP::CloudController::AppModel.make(space: space, desired_state: 'STARTED') + VCAP::CloudController::AppModel.make(space: space, desired_state: 'STOPPED') + ascending = %w[STARTED STARTED STOPPED STOPPED] + descending = ascending.reverse + + # ASCENDING + get '/v3/apps?order_by=state', nil, user_header + expect(last_response.status).to eq(200) + parsed_response = Oj.load(last_response.body) + app_states = parsed_response['resources'].pluck('state') + expect(app_states).to eq(ascending) + expect(parsed_response['pagination']['first']['href']).to include("order_by=#{CGI.escape('+')}state") + + # DESCENDING + get '/v3/apps?order_by=-state', nil, user_header + expect(last_response.status).to eq(200) + parsed_response = Oj.load(last_response.body) + app_states = parsed_response['resources'].pluck('state') + expect(app_states).to eq(descending) + expect(parsed_response['pagination']['first']['href']).to include('order_by=-state') + end + end + + context 'labels' do + let!(:app1) { VCAP::CloudController::AppModel.make(name: 'name1') } + let!(:app1_label) { VCAP::CloudController::AppLabelModel.make(resource_guid: app1.guid, key_name: 'foo', value: 'bar') } + + let!(:app2) { VCAP::CloudController::AppModel.make(name: 'name2') } + let!(:app2_label) { VCAP::CloudController::AppLabelModel.make(resource_guid: app2.guid, key_name: 'foo', value: 'funky') } + let!(:app2__exclusive_label) { VCAP::CloudController::AppLabelModel.make(resource_guid: app2.guid, key_name: 'santa', value: 'claus') } + + let(:admin_header) { headers_for(user, scopes: %w[cloud_controller.admin]) } + + it 'returns a 200 and the filtered apps for "in" label selector' do + get '/v3/apps?label_selector=foo in (bar)', nil, admin_header + + parsed_response = Oj.load(last_response.body) + + expected_pagination = { + 'total_results' => 1, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/apps?label_selector=foo+in+%28bar%29&page=1&per_page=50" }, + 'last' => { 'href' => "#{link_prefix}/v3/apps?label_selector=foo+in+%28bar%29&page=1&per_page=50" }, + 'next' => nil, + 'previous' => nil + } + + expect(last_response.status).to eq(200) + expect(parsed_response['resources'].pluck('guid')).to contain_exactly(app1.guid) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + + it 'returns a 200 and the filtered apps for "notin" label selector' do + get '/v3/apps?label_selector=foo notin (bar)', nil, admin_header + + parsed_response = Oj.load(last_response.body) + + expected_pagination = { + 'total_results' => 1, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/apps?label_selector=foo+notin+%28bar%29&page=1&per_page=50" }, + 'last' => { 'href' => "#{link_prefix}/v3/apps?label_selector=foo+notin+%28bar%29&page=1&per_page=50" }, + 'next' => nil, + 'previous' => nil + } + + expect(last_response.status).to eq(200) + expect(parsed_response['resources'].pluck('guid')).to contain_exactly(app2.guid) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + + it 'returns a 200 and the filtered apps for "=" label selector' do + get '/v3/apps?label_selector=foo=bar', nil, admin_header + + parsed_response = Oj.load(last_response.body) + + expected_pagination = { + 'total_results' => 1, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/apps?label_selector=foo%3Dbar&page=1&per_page=50" }, + 'last' => { 'href' => "#{link_prefix}/v3/apps?label_selector=foo%3Dbar&page=1&per_page=50" }, + 'next' => nil, + 'previous' => nil + } + + expect(last_response.status).to eq(200) + expect(parsed_response['resources'].pluck('guid')).to contain_exactly(app1.guid) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + + it 'returns a 200 and the filtered apps for "==" label selector' do + get '/v3/apps?label_selector=foo==bar', nil, admin_header + + parsed_response = Oj.load(last_response.body) + + expected_pagination = { + 'total_results' => 1, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/apps?label_selector=foo%3D%3Dbar&page=1&per_page=50" }, + 'last' => { 'href' => "#{link_prefix}/v3/apps?label_selector=foo%3D%3Dbar&page=1&per_page=50" }, + 'next' => nil, + 'previous' => nil + } + + expect(last_response.status).to eq(200) + expect(parsed_response['resources'].pluck('guid')).to contain_exactly(app1.guid) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + + it 'returns a 200 and the filtered apps for "!=" label selector' do + get '/v3/apps?label_selector=foo!=bar', nil, admin_header + + parsed_response = Oj.load(last_response.body) + + expected_pagination = { + 'total_results' => 1, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/apps?label_selector=foo%21%3Dbar&page=1&per_page=50" }, + 'last' => { 'href' => "#{link_prefix}/v3/apps?label_selector=foo%21%3Dbar&page=1&per_page=50" }, + 'next' => nil, + 'previous' => nil + } + + expect(last_response.status).to eq(200) + expect(parsed_response['resources'].pluck('guid')).to contain_exactly(app2.guid) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + + it 'returns a 200 and the filtered apps for "==" label selector' do + get '/v3/apps?label_selector=foo=funky,santa=claus', nil, admin_header + + parsed_response = Oj.load(last_response.body) + + expected_pagination = { + 'total_results' => 1, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/apps?label_selector=foo%3Dfunky%2Csanta%3Dclaus&page=1&per_page=50" }, + 'last' => { 'href' => "#{link_prefix}/v3/apps?label_selector=foo%3Dfunky%2Csanta%3Dclaus&page=1&per_page=50" }, + 'next' => nil, + 'previous' => nil + } + + expect(last_response.status).to eq(200) + expect(parsed_response['resources'].pluck('guid')).to contain_exactly(app2.guid) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + + it 'returns a 200 and the filtered apps for existence label selector' do + get '/v3/apps?label_selector=santa', nil, admin_header + + parsed_response = Oj.load(last_response.body) + + expected_pagination = { + 'total_results' => 1, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/apps?label_selector=santa&page=1&per_page=50" }, + 'last' => { 'href' => "#{link_prefix}/v3/apps?label_selector=santa&page=1&per_page=50" }, + 'next' => nil, + 'previous' => nil + } + + expect(last_response.status).to eq(200) + expect(parsed_response['resources'].pluck('guid')).to contain_exactly(app2.guid) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + + it 'returns a 200 and the filtered apps for non-existence label selector' do + get '/v3/apps?label_selector=!santa', nil, admin_header + + parsed_response = Oj.load(last_response.body) + + expected_pagination = { + 'total_results' => 1, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/apps?label_selector=%21santa&page=1&per_page=50" }, + 'last' => { 'href' => "#{link_prefix}/v3/apps?label_selector=%21santa&page=1&per_page=50" }, + 'next' => nil, + 'previous' => nil + } + + expect(last_response.status).to eq(200) + expect(parsed_response['resources'].pluck('guid')).to contain_exactly(app1.guid) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + end + + context 'labels and existing filters' do + let!(:space1) { VCAP::CloudController::Space.make } + let!(:space2) { VCAP::CloudController::Space.make } + let!(:app1) { VCAP::CloudController::AppModel.make(name: 'name1', space: space1) } + let!(:app2) { VCAP::CloudController::AppModel.make(name: 'name2', space: space2) } + let!(:app3) { VCAP::CloudController::AppModel.make(name: 'name3', space: space2) } + let!(:app1_label1) { VCAP::CloudController::AppLabelModel.make(resource_guid: app1.guid, key_name: 'foo', value: 'funky') } + let!(:app2_label1) { VCAP::CloudController::AppLabelModel.make(resource_guid: app2.guid, key_name: 'foo', value: 'funky') } + let!(:app2_label2) { VCAP::CloudController::AppLabelModel.make(resource_guid: app2.guid, key_name: 'fruit', value: 'strawberry') } + let!(:app3_label1) { VCAP::CloudController::AppLabelModel.make(resource_guid: app3.guid, key_name: 'fruit', value: 'strawberry') } + + let(:admin_header) { headers_for(user, scopes: %w[cloud_controller.admin]) } + + it 'returns a 200 and the correct app when querying with space guid' do + get "/v3/apps?space_guids=#{space2.guid}&label_selector=foo==funky", nil, admin_header + + parsed_response = Oj.load(last_response.body) + + expected_pagination = { + 'total_results' => 1, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/apps?label_selector=foo%3D%3Dfunky&page=1&per_page=50&space_guids=#{space2.guid}" }, + 'last' => { 'href' => "#{link_prefix}/v3/apps?label_selector=foo%3D%3Dfunky&page=1&per_page=50&space_guids=#{space2.guid}" }, + 'next' => nil, + 'previous' => nil + } + + expect(last_response.status).to eq(200) + expect(parsed_response['resources'].pluck('guid')).to contain_exactly(app2.guid) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + + it 'returns a 200 and the correct app when querying with space guid' do + get "/v3/apps?space_guids=#{space2.guid}&label_selector=fruit==strawberry&names=name2", nil, admin_header + + parsed_response = Oj.load(last_response.body) + + expected_pagination = { + 'total_results' => 1, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/apps?label_selector=fruit%3D%3Dstrawberry&names=name2&page=1&per_page=50&space_guids=#{space2.guid}" }, + 'last' => { 'href' => "#{link_prefix}/v3/apps?label_selector=fruit%3D%3Dstrawberry&names=name2&page=1&per_page=50&space_guids=#{space2.guid}" }, + 'next' => nil, + 'previous' => nil + } + + expect(last_response.status).to eq(200) + expect(parsed_response['resources'].pluck('guid')).to contain_exactly(app2.guid) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + end + + context 'including orgs and spaces' do + it 'presents the apps listed with the orgs and spaces included' do + VCAP::CloudController::AppModel.make(:docker, name: 'name1', guid: 'app1-guid', space: space) + + org1 = space.organization + org2 = VCAP::CloudController::Organization.make(name: 'org2', guid: 'org2-guid', created_at: 1.day.ago) + space2 = VCAP::CloudController::Space.make(name: 'space2', guid: 'space2-guid', organization: org2) + + unused_org = VCAP::CloudController::Organization.make(name: 'unused_org', guid: 'unused_org-guid') + + VCAP::CloudController::Space.make(name: 'unused_space', guid: 'unused_space-guid', organization: unused_org) + + VCAP::CloudController::AppModel.make( + :docker, + name: 'name2', + guid: 'app2-guid', + space: space2 + ) + + get '/v3/apps?per_page=2&include=space,space.organization', nil, admin_header + expect(last_response.status).to eq(200) + + parsed_response = Oj.load(last_response.body) + + expect(parsed_response['included']['organizations'][0]).to be_a_response_like({ + 'guid' => org1.guid, + 'created_at' => iso8601, + 'updated_at' => iso8601, + 'name' => org1.name, + 'metadata' => { + 'labels' => {}, + 'annotations' => {} + }, + 'suspended' => false, + 'links' => { + 'self' => { + 'href' => "#{link_prefix}/v3/organizations/#{org1.guid}" + }, + 'default_domain' => { + 'href' => "#{link_prefix}/v3/organizations/#{org1.guid}/domains/default" + }, + 'domains' => { + 'href' => "#{link_prefix}/v3/organizations/#{org1.guid}/domains" + }, + 'quota' => { + 'href' => "#{link_prefix}/v3/organization_quotas/#{org1.quota_definition.guid}" + } + }, + 'relationships' => { 'quota' => { 'data' => { 'guid' => org1.quota_definition.guid } } } + }) + expect(parsed_response['included']['organizations'][1]).to be_a_response_like({ + 'guid' => org2.guid, + 'created_at' => iso8601, + 'updated_at' => iso8601, + 'name' => org2.name, + 'suspended' => false, + 'metadata' => { + 'labels' => {}, + 'annotations' => {} + }, + 'links' => { + 'self' => { + 'href' => "#{link_prefix}/v3/organizations/#{org2.guid}" + }, + 'default_domain' => { + 'href' => "#{link_prefix}/v3/organizations/#{org2.guid}/domains/default" + }, + 'domains' => { + 'href' => "#{link_prefix}/v3/organizations/#{org2.guid}/domains" + }, + 'quota' => { + 'href' => "#{link_prefix}/v3/organization_quotas/#{org2.quota_definition.guid}" + } + }, + 'relationships' => { 'quota' => { 'data' => { 'guid' => org2.quota_definition.guid } } } + }) + end + + it 'flags unsupported includes that contain supported ones' do + get '/v3/apps?per_page=2&include=space.organization,spaceship,borgs,space', nil, admin_header + expect(last_response.status).to eq(400) + end + + it 'does not include spaces if no one asks for them' do + get '/v3/apps', nil, admin_header + parsed_response = Oj.load(last_response.body) + expect(parsed_response).not_to have_key('included') + end + end + + context 'when including orgs' do + before do + VCAP::CloudController::AppModel.make + end + + it 'eagerly loads spaces to efficiently access space.organization_id' do + expect(VCAP::CloudController::IncludeOrganizationDecorator).to receive(:decorate) do |_, resources| + expect(resources).not_to be_empty + resources.each { |r| expect(r.associations).to include(:space) } + end + + get '/v3/apps?include=space.organization', nil, admin_header + expect(last_response).to have_status_code(200) + end + end + end +end diff --git a/spec/request/apps/shared_context.rb b/spec/request/apps/shared_context.rb new file mode 100644 index 00000000000..e2d74fc0a4f --- /dev/null +++ b/spec/request/apps/shared_context.rb @@ -0,0 +1,10 @@ +RSpec.shared_context 'apps request spec' do + let(:user) { VCAP::CloudController::User.make } + let(:user_header) { headers_for(user, email: user_email, user_name: user_name) } + let(:admin_header) { admin_headers_for(user) } + let(:org) { VCAP::CloudController::Organization.make(created_at: 3.days.ago) } + let(:space) { VCAP::CloudController::Space.make(organization: org) } + let(:stack) { VCAP::CloudController::Stack.make } + let(:user_email) { Sham.email } + let(:user_name) { 'some-username' } +end diff --git a/spec/request/apps/show_spec.rb b/spec/request/apps/show_spec.rb new file mode 100644 index 00000000000..7c81205f9d2 --- /dev/null +++ b/spec/request/apps/show_spec.rb @@ -0,0 +1,495 @@ +require 'spec_helper' +require 'actions/process_create_from_app_droplet' +require 'request_spec_shared_examples' +require_relative 'shared_context' + +# Split from spec/request/apps_spec.rb for better test parallelization + +RSpec.describe 'Apps' do + include_context 'apps request spec' + + describe 'GET /v3/apps/:guid' do + let!(:buildpack) { VCAP::CloudController::Buildpack.make(name: 'bp-name') } + let!(:stack) { VCAP::CloudController::Stack.make(name: 'stack-name') } + let!(:app_model) do + VCAP::CloudController::AppModel.make( + :buildpack, + name: 'my_app', + guid: 'app1_guid', + space: space, + desired_state: 'STARTED', + environment_variables: { 'unicorn' => 'horn' } + ) + end + + before do + space.organization.add_user(user) + app_model.lifecycle_data.buildpacks = [buildpack.name] + app_model.lifecycle_data.stack = stack.name + app_model.lifecycle_data.save + app_model.add_process(VCAP::CloudController::ProcessModel.make(instances: 1)) + app_model.add_process(VCAP::CloudController::ProcessModel.make(instances: 2)) + end + + context 'when getting an app' do + let(:api_call) { ->(user_headers) { get "/v3/apps/#{app_model.guid}", nil, user_headers } } + + let(:app_model_response_object) do + { + guid: app_model.guid, + created_at: iso8601, + updated_at: iso8601, + name: app_model.name, + state: 'STARTED', + lifecycle: { + type: 'buildpack', + data: { buildpacks: [buildpack.name], stack: app_model.lifecycle_data.stack } + }, + relationships: { + space: { data: { guid: space.guid } }, + current_droplet: { data: { guid: app_model.droplet_guid } } + }, + metadata: { + labels: {}, + annotations: {} + }, + links: { + self: { href: "#{link_prefix}/v3/apps/app1_guid" }, + environment_variables: { href: "#{link_prefix}/v3/apps/app1_guid/environment_variables" }, + space: { href: "#{link_prefix}/v3/spaces/#{space.guid}" }, + processes: { href: "#{link_prefix}/v3/apps/app1_guid/processes" }, + packages: { href: "#{link_prefix}/v3/apps/app1_guid/packages" }, + current_droplet: { href: "#{link_prefix}/v3/apps/app1_guid/droplets/current" }, + droplets: { href: "#{link_prefix}/v3/apps/app1_guid/droplets" }, + tasks: { href: "#{link_prefix}/v3/apps/app1_guid/tasks" }, + start: { href: "#{link_prefix}/v3/apps/app1_guid/actions/start", method: 'POST' }, + stop: { href: "#{link_prefix}/v3/apps/app1_guid/actions/stop", method: 'POST' }, + clear_buildpack_cache: { href: "#{link_prefix}/v3/apps/app1_guid/actions/clear_buildpack_cache", method: 'POST' }, + revisions: { href: "#{link_prefix}/v3/apps/app1_guid/revisions" }, + deployed_revisions: { href: "#{link_prefix}/v3/apps/app1_guid/revisions/deployed" }, + features: { href: "#{link_prefix}/v3/apps/app1_guid/features" } + } + } + end + + let(:expected_codes_and_responses) do + h = Hash.new({ code: 200, response_object: app_model_response_object }.freeze) + h['org_auditor'] = { code: 404 } + h['org_billing_manager'] = { code: 404 } + h['no_role'] = { code: 404 } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + + context 'when the user has permission to view the app' do + before do + space.add_developer(user) + end + + it 'gets a specific app' do + get "/v3/apps/#{app_model.guid}", nil, user_header + expect(last_response.status).to eq(200) + + parsed_response = Oj.load(last_response.body) + expect(parsed_response).to be_a_response_like( + { + 'name' => 'my_app', + 'guid' => app_model.guid, + 'state' => 'STARTED', + 'created_at' => iso8601, + 'updated_at' => iso8601, + 'metadata' => { 'labels' => {}, 'annotations' => {} }, + 'lifecycle' => { + 'type' => 'buildpack', + 'data' => { + 'buildpacks' => ['bp-name'], + 'stack' => 'stack-name' + } + }, + 'relationships' => { + 'space' => { + 'data' => { + 'guid' => space.guid + } + }, + 'current_droplet' => { + 'data' => { + 'guid' => app_model.droplet_guid + } + } + }, + 'links' => { + 'self' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}" }, + 'processes' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/processes" }, + 'packages' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/packages" }, + 'environment_variables' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/environment_variables" }, + 'space' => { 'href' => "#{link_prefix}/v3/spaces/#{space.guid}" }, + 'current_droplet' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/droplets/current" }, + 'droplets' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/droplets" }, + 'tasks' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/tasks" }, + 'start' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/start", 'method' => 'POST' }, + 'stop' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/stop", 'method' => 'POST' }, + 'clear_buildpack_cache' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/clear_buildpack_cache", 'method' => 'POST' }, + 'revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/revisions" }, + 'deployed_revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/revisions/deployed" }, + 'features' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/features" } + } + } + ) + end + + it 'gets a specific app including space' do + get "/v3/apps/#{app_model.guid}?include=space", nil, user_header + expect(last_response.status).to eq(200) + + parsed_response = Oj.load(last_response.body) + expect(parsed_response).to be_a_response_like( + { + 'name' => 'my_app', + 'guid' => app_model.guid, + 'state' => 'STARTED', + 'created_at' => iso8601, + 'updated_at' => iso8601, + 'metadata' => { 'labels' => {}, 'annotations' => {} }, + 'lifecycle' => { + 'type' => 'buildpack', + 'data' => { + 'buildpacks' => ['bp-name'], + 'stack' => 'stack-name' + } + }, + 'relationships' => { + 'space' => { + 'data' => { + 'guid' => space.guid + } + }, + 'current_droplet' => { + 'data' => { + 'guid' => app_model.droplet_guid + } + } + }, + 'links' => { + 'self' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}" }, + 'processes' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/processes" }, + 'packages' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/packages" }, + 'environment_variables' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/environment_variables" }, + 'space' => { 'href' => "#{link_prefix}/v3/spaces/#{space.guid}" }, + 'current_droplet' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/droplets/current" }, + 'droplets' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/droplets" }, + 'tasks' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/tasks" }, + 'start' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/start", 'method' => 'POST' }, + 'stop' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/stop", 'method' => 'POST' }, + 'clear_buildpack_cache' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/clear_buildpack_cache", 'method' => 'POST' }, + 'revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/revisions" }, + 'deployed_revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/revisions/deployed" }, + 'features' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/features" } + }, + 'included' => { + 'spaces' => [{ + 'guid' => space.guid, + 'created_at' => iso8601, + 'updated_at' => iso8601, + 'name' => space.name, + 'relationships' => { + 'organization' => { + 'data' => { + 'guid' => space.organization.guid + } + }, + 'quota' => { + 'data' => nil + } + }, + 'metadata' => { + 'labels' => {}, + 'annotations' => {} + }, + 'links' => { + 'self' => { + 'href' => "#{link_prefix}/v3/spaces/#{space.guid}" + }, + 'organization' => { + 'href' => "#{link_prefix}/v3/organizations/#{space.organization.guid}" + }, + 'features' => { 'href' => %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}/features} }, + 'apply_manifest' => { + 'href' => "#{link_prefix}/v3/spaces/#{space.guid}/actions/apply_manifest", + 'method' => 'POST' + } + } + }] + } + } + ) + end + + it 'gets a specific app including space and org' do + get "/v3/apps/#{app_model.guid}?include=space.organization", nil, user_header + expect(last_response.status).to eq(200) + + parsed_response = Oj.load(last_response.body) + spaces = parsed_response['included']['spaces'] + orgs = parsed_response['included']['organizations'] + + expect(spaces).to be_present + expect(orgs[0]).to be_a_response_like( + { + 'guid' => org.guid, + 'created_at' => iso8601, + 'updated_at' => iso8601, + 'name' => org.name, + 'metadata' => { + 'labels' => {}, + 'annotations' => {} + }, + 'suspended' => false, + 'links' => { + 'self' => { + 'href' => "#{link_prefix}/v3/organizations/#{org.guid}" + }, + 'default_domain' => { + 'href' => "#{link_prefix}/v3/organizations/#{org.guid}/domains/default" + }, + 'domains' => { + 'href' => "#{link_prefix}/v3/organizations/#{org.guid}/domains" + }, + 'quota' => { + 'href' => "#{link_prefix}/v3/organization_quotas/#{org.quota_definition.guid}" + } + }, + 'relationships' => { 'quota' => { 'data' => { 'guid' => org.quota_definition.guid } } } + } + ) + end + end + end + + describe 'GET /v3/apps/:guid/env' do + before do + space.organization.add_user(user) + end + + context 'when getting an apps environment variables' do + let(:api_call) { ->(user_headers) { get "/v3/apps/#{app_model.guid}/env", nil, user_headers } } + let!(:app_model) do + VCAP::CloudController::AppModel.make( + :buildpack, + name: 'my_app', + guid: 'app1_guid', + space: space, + environment_variables: { 'unicorn' => 'horn' } + ) + end + + let(:app_model_response_object) do + { + environment_variables: app_model.environment_variables, + staging_env_json: {}, + running_env_json: {}, + system_env_json: { VCAP_SERVICES: {} }, + application_env_json: anything + } + end + let(:app_model_empty_system_env_response_object) do + { + environment_variables: app_model.environment_variables, + staging_env_json: {}, + running_env_json: {}, + system_env_json: { + redacted_message: '[PRIVATE DATA HIDDEN]' + }, + application_env_json: anything + } + end + + let(:expected_codes_and_responses) do + h = Hash.new({ code: 200, response_object: app_model_response_object }.freeze) + h['space_supporter'] = { code: 200, response_object: app_model_empty_system_env_response_object } + h['global_auditor'] = h['org_manager'] = h['space_manager'] = h['space_auditor'] = { code: 403 } + h['org_auditor'] = h['org_billing_manager'] = h['no_role'] = { code: 404 } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + + context 'when k8s service bindings are enabled' do + let(:app_model_response_object) do + r = super() + r[:system_env_json] = { SERVICE_BINDING_ROOT: '/etc/cf-service-bindings' } + r + end + + before do + app_model.update(service_binding_k8s_enabled: true) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + + context 'when file-based VCAP service bindings are enabled' do + let(:app_model_response_object) do + r = super() + r[:system_env_json] = { VCAP_SERVICES_FILE_PATH: '/etc/cf-service-bindings/vcap_services' } + r + end + + before do + app_model.update(file_based_vcap_services_enabled: true) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + end + + context 'when VCAP_SERVICES contains potentially sensitive information' do + before do + group = VCAP::CloudController::EnvironmentVariableGroup.staging + group.environment_json = { STAGING_ENV: 'staging_value' } + group.save + + group = VCAP::CloudController::EnvironmentVariableGroup.running + group.environment_json = { RUNNING_ENV: 'running_value' } + group.save + end + + let(:api_call) { ->(user_headers) { get "/v3/apps/#{app_model.guid}/env", nil, user_headers } } + let(:app_model) do + VCAP::CloudController::AppModel.make( + name: 'my_app', + space: space, + environment_variables: { 'unicorn' => 'horn' } + ) + end + let(:service_instance) do + VCAP::CloudController::ManagedServiceInstance.make( + space: space, + name: 'si-name', + tags: ['50% off'] + ) + end + let(:service_binding) do + VCAP::CloudController::ServiceBinding.make( + service_instance: service_instance, + app: app_model, + syslog_drain_url: 'https://syslog.example.com/drain', + credentials: { password: 'top-secret' } + ) + end + let(:expected_response) do + { + 'staging_env_json' => { + 'STAGING_ENV' => 'staging_value' + }, + 'running_env_json' => { + 'RUNNING_ENV' => 'running_value' + }, + 'environment_variables' => { + 'unicorn' => 'horn' + }, + 'system_env_json' => { + 'VCAP_SERVICES' => { + service_instance.service.label => [ + { + 'name' => 'si-name', + 'instance_guid' => service_instance.guid, + 'instance_name' => 'si-name', + 'binding_guid' => service_binding.guid, + 'binding_name' => nil, + 'credentials' => { 'password' => 'top-secret' }, + 'syslog_drain_url' => 'https://syslog.example.com/drain', + 'volume_mounts' => [], + 'label' => service_instance.service.label, + 'provider' => nil, + 'plan' => service_instance.service_plan.name, + 'tags' => ['50% off'] + } + ] + } + }, + 'application_env_json' => { + 'VCAP_APPLICATION' => { + 'cf_api' => "#{TestConfig.config[:external_protocol]}://#{TestConfig.config[:external_domain]}", + 'limits' => { + 'fds' => 16_384 + }, + 'application_name' => 'my_app', + 'application_uris' => [], + 'name' => 'my_app', + 'organization_id' => space.organization.guid, + 'organization_name' => space.organization.name, + 'space_id' => space.guid, + 'space_name' => space.name, + 'uris' => [], + 'users' => nil, + 'application_id' => app_model.guid + } + } + } + end + + let(:expected_response_system_env_redacted) do + { + 'staging_env_json' => { + 'STAGING_ENV' => 'staging_value' + }, + 'running_env_json' => { + 'RUNNING_ENV' => 'running_value' + }, + 'environment_variables' => { + 'unicorn' => 'horn' + }, + 'system_env_json' => { + 'redacted_message' => '[PRIVATE DATA HIDDEN]' + }, + 'application_env_json' => { + 'VCAP_APPLICATION' => { + 'cf_api' => "#{TestConfig.config[:external_protocol]}://#{TestConfig.config[:external_domain]}", + 'limits' => { + 'fds' => 16_384 + }, + 'application_name' => 'my_app', + 'application_uris' => [], + 'name' => 'my_app', + 'organization_id' => space.organization.guid, + 'organization_name' => space.organization.name, + 'space_id' => space.guid, + 'space_name' => space.name, + 'uris' => [], + 'users' => nil, + 'application_id' => app_model.guid + } + } + } + end + + let(:expected_codes_and_responses) do + h = Hash.new({ code: 403 }.freeze) + h['admin'] = h['admin_read_only'] = h['space_developer'] = { code: 200, response_object: expected_response } + h['space_supporter'] = { code: 200, response_object: expected_response_system_env_redacted } + h['org_auditor'] = h['org_billing_manager'] = h['no_role'] = { code: 404 } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + + 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_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS do + let(:expected_codes_and_responses) do + h = Hash.new({ code: 403 }.freeze) + h['admin'] = h['admin_read_only'] = { code: 200, response_object: expected_response } + h['org_auditor'] = h['org_billing_manager'] = h['no_role'] = { code: 404 } + h + end + end + end + end + end +end From 67690e95a8de526cac6e0d6e1386687aa55517a6 Mon Sep 17 00:00:00 2001 From: johha Date: Mon, 2 Mar 2026 15:45:13 +0100 Subject: [PATCH 11/20] Split routes_spec.rb into 6 smaller files for better parallelization Original file: spec/request/routes_spec.rb (3,748 lines, 401 examples) **Deleted original file after splitting** Split into: - list_spec.rb (GET /v3/routes) - 937 lines - show_spec.rb (GET /v3/routes/:guid) - 171 lines - create_spec.rb (POST /v3/routes) - 1289 lines - update_and_delete_spec.rb - 277 lines - sharing_spec.rb (shared_spaces relationships) - 917 lines - apps_routes_spec.rb (GET /v3/apps/:app_guid/routes) - 173 lines Shared test setup extracted to shared_context.rb This split enables better parallel test distribution. --- spec/request/apps_spec.rb | 3542 ---------------- spec/request/routes/apps_routes_spec.rb | 173 + spec/request/routes/create_spec.rb | 1290 ++++++ spec/request/routes/list_spec.rb | 938 +++++ spec/request/routes/shared_context.rb | 31 + spec/request/routes/sharing_spec.rb | 918 ++++ spec/request/routes/show_spec.rb | 172 + spec/request/routes/update_and_delete_spec.rb | 278 ++ spec/request/routes_spec.rb | 3748 ----------------- 9 files changed, 3800 insertions(+), 7290 deletions(-) delete mode 100644 spec/request/apps_spec.rb create mode 100644 spec/request/routes/apps_routes_spec.rb create mode 100644 spec/request/routes/create_spec.rb create mode 100644 spec/request/routes/list_spec.rb create mode 100644 spec/request/routes/shared_context.rb create mode 100644 spec/request/routes/sharing_spec.rb create mode 100644 spec/request/routes/show_spec.rb create mode 100644 spec/request/routes/update_and_delete_spec.rb delete mode 100644 spec/request/routes_spec.rb diff --git a/spec/request/apps_spec.rb b/spec/request/apps_spec.rb deleted file mode 100644 index 64fcef98a77..00000000000 --- a/spec/request/apps_spec.rb +++ /dev/null @@ -1,3542 +0,0 @@ -require 'spec_helper' -require 'actions/process_create_from_app_droplet' -require 'request_spec_shared_examples' - -RSpec.describe 'Apps' do - let(:user) { VCAP::CloudController::User.make } - let(:user_header) { headers_for(user, email: user_email, user_name: user_name) } - let(:admin_header) { admin_headers_for(user) } - let(:org) { VCAP::CloudController::Organization.make(created_at: 3.days.ago) } - let(:space) { VCAP::CloudController::Space.make(organization: org) } - let(:stack) { VCAP::CloudController::Stack.make } - let(:user_email) { Sham.email } - let(:user_name) { 'some-username' } - - describe 'POST /v3/apps' do - let(:buildpack) { VCAP::CloudController::Buildpack.make(stack: stack.name) } - let(:create_request) do - { - name: 'my_app', - environment_variables: { open: 'source' }, - lifecycle: { - type: 'buildpack', - data: { - stack: buildpack.stack, - buildpacks: [buildpack.name] - } - }, - relationships: { - space: { - data: { - guid: space.guid - } - } - }, - metadata: { - labels: { - 'release' => 'stable', - 'code.cloudfoundry.org/cloud_controller_ng' => 'awesome' - }, - annotations: { - 'description' => 'gud app', - 'dora.capi.land/stuff' => 'real gud stuff' - } - } - } - end - - context 'permissions for creating an app' do - let(:api_call) { ->(user_headers) { post '/v3/apps', create_request.to_json, user_headers } } - let(:app_model_response_object) do - { - guid: UUID_REGEX, - created_at: iso8601, - updated_at: iso8601, - name: 'my_app', - state: 'STOPPED', - lifecycle: { - type: 'buildpack', - data: { buildpacks: [buildpack.name], stack: stack.name } - }, - relationships: { - space: { data: { guid: space.guid } }, - current_droplet: { data: { guid: nil } } - }, - metadata: { - labels: { - 'code.cloudfoundry.org/cloud_controller_ng' => 'awesome', - 'release' => 'stable' - }, - annotations: { - 'dora.capi.land/stuff' => 'real gud stuff', - 'description' => 'gud app' - } - }, - links: { - self: { href: %r{#{link_prefix}/v3/apps/#{UUID_REGEX}} }, - environment_variables: { href: %r{#{link_prefix}/v3/apps/#{UUID_REGEX}/environment_variables} }, - space: { href: %r{#{link_prefix}/v3/spaces/#{space.guid}} }, - processes: { href: %r{#{link_prefix}/v3/apps/#{UUID_REGEX}/processes} }, - packages: { href: %r{#{link_prefix}/v3/apps/#{UUID_REGEX}/packages} }, - current_droplet: { href: %r{#{link_prefix}/v3/apps/#{UUID_REGEX}/droplets/current} }, - droplets: { href: %r{#{link_prefix}/v3/apps/#{UUID_REGEX}/droplets} }, - tasks: { href: %r{#{link_prefix}/v3/apps/#{UUID_REGEX}/tasks} }, - start: { href: %r{#{link_prefix}/v3/apps/#{UUID_REGEX}/actions/start}, method: 'POST' }, - stop: { href: %r{#{link_prefix}/v3/apps/#{UUID_REGEX}/actions/stop}, method: 'POST' }, - clear_buildpack_cache: { href: %r{#{link_prefix}/v3/apps/#{UUID_REGEX}/actions/clear_buildpack_cache}, method: 'POST' }, - revisions: { href: %r{#{link_prefix}/v3/apps/#{UUID_REGEX}/revisions} }, - deployed_revisions: { href: %r{#{link_prefix}/v3/apps/#{UUID_REGEX}/revisions/deployed} }, - features: { href: %r{#{link_prefix}/v3/apps/#{UUID_REGEX}/features} } - } - } - end - - let(:expected_codes_and_responses) do - h = Hash.new({ code: 403, errors: CF_NOT_AUTHORIZED }.freeze) - h['org_billing_manager'] = { code: 422 } - h['org_auditor'] = { code: 422 } - h['no_role'] = { code: 422 } - h['admin'] = { - code: 201, - response_object: app_model_response_object - } - h['space_developer'] = { - code: 201, - response_object: app_model_response_object - } - h - end - - before do - space.organization.add_user(user) - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - - context 'when organization is suspended' do - let(:expected_codes_and_responses) do - h = super() - h['space_developer'] = { code: 403, errors: CF_ORG_SUSPENDED } - h - end - - before do - org.update(status: VCAP::CloudController::Organization::SUSPENDED) - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - end - - context 'when the user can create an app' do - before do - space.organization.add_user(user) - space.add_developer(user) - end - - it 'creates an app' do - post '/v3/apps', create_request.to_json, user_header - expect(last_response.status).to eq(201) - - parsed_response = Oj.load(last_response.body) - app_guid = parsed_response['guid'] - - expect(VCAP::CloudController::AppModel.find(guid: app_guid)).to be - expect(parsed_response).to be_a_response_like( - { - 'name' => 'my_app', - 'guid' => app_guid, - 'state' => 'STOPPED', - 'lifecycle' => { - 'type' => 'buildpack', - 'data' => { - 'buildpacks' => [buildpack.name], - 'stack' => stack.name - } - }, - 'relationships' => { - 'space' => { - 'data' => { - 'guid' => space.guid - } - }, - 'current_droplet' => { - 'data' => { - 'guid' => nil - } - } - }, - 'created_at' => iso8601, - 'updated_at' => iso8601, - 'metadata' => { - 'labels' => { - 'release' => 'stable', - 'code.cloudfoundry.org/cloud_controller_ng' => 'awesome' - }, - 'annotations' => { - 'description' => 'gud app', - 'dora.capi.land/stuff' => 'real gud stuff' - } - }, - 'links' => { - 'self' => { 'href' => "#{link_prefix}/v3/apps/#{app_guid}" }, - 'processes' => { 'href' => "#{link_prefix}/v3/apps/#{app_guid}/processes" }, - 'packages' => { 'href' => "#{link_prefix}/v3/apps/#{app_guid}/packages" }, - 'environment_variables' => { 'href' => "#{link_prefix}/v3/apps/#{app_guid}/environment_variables" }, - 'space' => { 'href' => "#{link_prefix}/v3/spaces/#{space.guid}" }, - 'current_droplet' => { 'href' => "#{link_prefix}/v3/apps/#{app_guid}/droplets/current" }, - 'droplets' => { 'href' => "#{link_prefix}/v3/apps/#{app_guid}/droplets" }, - 'tasks' => { 'href' => "#{link_prefix}/v3/apps/#{app_guid}/tasks" }, - 'start' => { 'href' => "#{link_prefix}/v3/apps/#{app_guid}/actions/start", 'method' => 'POST' }, - 'stop' => { 'href' => "#{link_prefix}/v3/apps/#{app_guid}/actions/stop", 'method' => 'POST' }, - 'clear_buildpack_cache' => { 'href' => "#{link_prefix}/v3/apps/#{app_guid}/actions/clear_buildpack_cache", 'method' => 'POST' }, - 'revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_guid}/revisions" }, - 'deployed_revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_guid}/revisions/deployed" }, - 'features' => { 'href' => "#{link_prefix}/v3/apps/#{app_guid}/features" } - } - } - ) - - event = VCAP::CloudController::Event.last - expect(event.values).to include({ - type: 'audit.app.create', - actee: app_guid, - actee_type: 'app', - actee_name: 'my_app', - actor: user.guid, - actor_type: 'user', - actor_name: user_email, - actor_username: user_name, - space_guid: space.guid, - organization_guid: space.organization.guid - }) - end - - it 'creates an empty web process with the same guid as the app (so it is visible on the v2 apps api)' do - post '/v3/apps', create_request.to_json, user_header - expect(last_response.status).to eq(201) - - parsed_response = Oj.load(last_response.body) - app_guid = parsed_response['guid'] - expect(VCAP::CloudController::AppModel.find(guid: app_guid)).not_to be_nil - expect(VCAP::CloudController::ProcessModel.find(guid: app_guid)).not_to be_nil - end - - context 'telemetry' do - let(:logger_spy) { spy('logger') } - - before do - allow(VCAP::CloudController::TelemetryLogger).to receive(:logger).and_return(logger_spy) - end - - it 'logs the required fields when the app is created' do - Timecop.freeze do - post '/v3/apps', create_request.to_json, user_header - - parsed_response = Oj.load(last_response.body) - app_guid = parsed_response['guid'] - - expected_json = { - 'telemetry-source' => 'cloud_controller_ng', - 'telemetry-time' => Time.now.to_datetime.rfc3339, - 'create-app' => { - 'api-version' => 'v3', - 'app-id' => OpenSSL::Digest::SHA256.hexdigest(app_guid), - 'user-id' => OpenSSL::Digest::SHA256.hexdigest(user.guid) - } - }.to_json - expect(logger_spy).to have_received(:info).with(expected_json) - expect(last_response.status).to eq(201), last_response.body - end - end - end - - context 'Docker app' do - before do - VCAP::CloudController::FeatureFlag.make(name: 'diego_docker', enabled: true, error_message: nil) - end - - it 'create a docker app' do - create_request = { - name: 'my_app', - environment_variables: { open: 'source' }, - lifecycle: { - type: 'docker', - data: {} - }, - relationships: { - space: { data: { guid: space.guid } } - } - } - - post '/v3/apps', create_request.to_json, user_header.merge({ 'CONTENT_TYPE' => 'application/json' }) - expect(last_response.status).to eq(201), last_response.body - - created_app = VCAP::CloudController::AppModel.last - expected_response = { - 'name' => 'my_app', - 'guid' => created_app.guid, - 'state' => 'STOPPED', - 'lifecycle' => { - 'type' => 'docker', - 'data' => {} - }, - 'relationships' => { - 'space' => { - 'data' => { - 'guid' => space.guid - } - }, - 'current_droplet' => { - 'data' => { - 'guid' => nil - } - } - }, - 'created_at' => iso8601, - 'updated_at' => iso8601, - 'metadata' => { 'labels' => {}, 'annotations' => {} }, - 'links' => { - 'self' => { 'href' => "#{link_prefix}/v3/apps/#{created_app.guid}" }, - 'processes' => { 'href' => "#{link_prefix}/v3/apps/#{created_app.guid}/processes" }, - 'packages' => { 'href' => "#{link_prefix}/v3/apps/#{created_app.guid}/packages" }, - 'environment_variables' => { 'href' => "#{link_prefix}/v3/apps/#{created_app.guid}/environment_variables" }, - 'space' => { 'href' => "#{link_prefix}/v3/spaces/#{space.guid}" }, - 'current_droplet' => { 'href' => "#{link_prefix}/v3/apps/#{created_app.guid}/droplets/current" }, - 'droplets' => { 'href' => "#{link_prefix}/v3/apps/#{created_app.guid}/droplets" }, - 'tasks' => { 'href' => "#{link_prefix}/v3/apps/#{created_app.guid}/tasks" }, - 'start' => { 'href' => "#{link_prefix}/v3/apps/#{created_app.guid}/actions/start", 'method' => 'POST' }, - 'stop' => { 'href' => "#{link_prefix}/v3/apps/#{created_app.guid}/actions/stop", 'method' => 'POST' }, - 'clear_buildpack_cache' => { 'href' => "#{link_prefix}/v3/apps/#{created_app.guid}/actions/clear_buildpack_cache", 'method' => 'POST' }, - 'revisions' => { 'href' => "#{link_prefix}/v3/apps/#{created_app.guid}/revisions" }, - 'deployed_revisions' => { 'href' => "#{link_prefix}/v3/apps/#{created_app.guid}/revisions/deployed" }, - 'features' => { 'href' => "#{link_prefix}/v3/apps/#{created_app.guid}/features" } - } - } - - parsed_response = Oj.load(last_response.body) - expect(parsed_response).to be_a_response_like(expected_response) - - event = VCAP::CloudController::Event.last - expect(event.values).to include( - type: 'audit.app.create', - actee: created_app.guid, - actee_type: 'app', - actee_name: 'my_app', - actor: user.guid, - actor_type: 'user', - actor_name: user_email, - actor_username: user_name, - space_guid: space.guid, - organization_guid: space.organization.guid - ) - end - end - - context 'cc.default_app_lifecycle' do - let(:create_request) do - { - name: 'my_app', - relationships: { - space: { - data: { - guid: space.guid - } - } - } - } - end - - context 'cc.default_app_lifecycle is set to buildpack' do - before do - TestConfig.override(default_app_lifecycle: 'buildpack') - end - - it 'creates an app with the buildpack lifecycle when none is specified in the request' do - post '/v3/apps', create_request.to_json, user_header - - expect(last_response.status).to eq(201) - parsed_response = Oj.load(last_response.body) - expect(parsed_response['lifecycle']['type']).to eq('buildpack') - end - end - end - end - - context 'stack state validation' do - before do - space.organization.add_user(user) - space.add_developer(user) - end - - context 'when stack is DISABLED' do - let(:disabled_stack) { VCAP::CloudController::Stack.make(name: 'disabled-stack', state: 'DISABLED') } - let(:create_request) do - { - name: 'my_app', - lifecycle: { type: 'buildpack', data: { stack: disabled_stack.name } }, - relationships: { space: { data: { guid: space.guid } } } - } - end - - it 'returns 422 with error message' do - post '/v3/apps', create_request.to_json, user_header - - expect(last_response.status).to eq(422) - expect(parsed_response['errors'].first['detail']).to include('DISABLED') - end - end - - context 'when stack is RESTRICTED' do - let(:restricted_stack) { VCAP::CloudController::Stack.make(name: 'restricted-stack', state: 'RESTRICTED') } - let(:create_request) do - { - name: 'my_app', - lifecycle: { type: 'buildpack', data: { stack: restricted_stack.name } }, - relationships: { space: { data: { guid: space.guid } } } - } - end - - it 'returns 422 with error message for new apps' do - post '/v3/apps', create_request.to_json, user_header - - expect(last_response.status).to eq(422) - expect(parsed_response['errors'].first['detail']).to include('RESTRICTED') - end - end - - context 'when stack is DEPRECATED' do - let(:deprecated_stack) { VCAP::CloudController::Stack.make(name: 'deprecated-stack', state: 'DEPRECATED') } - let(:create_request) do - { - name: 'my_app', - lifecycle: { type: 'buildpack', data: { stack: deprecated_stack.name } }, - relationships: { space: { data: { guid: space.guid } } } - } - end - - it 'creates the app without warnings in response body' do - post '/v3/apps', create_request.to_json, user_header - - expect(last_response.status).to eq(201) - expect(parsed_response).not_to have_key('warnings') - end - - it 'includes warnings in X-Cf-Warnings header' do - post '/v3/apps', create_request.to_json, user_header - - expect(last_response.status).to eq(201) - expect(last_response.headers['X-Cf-Warnings']).to be_present - decoded_warning = CGI.unescape(last_response.headers['X-Cf-Warnings']) - expect(decoded_warning).to include('DEPRECATED') - end - end - - context 'when stack is ACTIVE' do - let(:active_stack) { VCAP::CloudController::Stack.make(name: 'active-stack', state: 'ACTIVE') } - let(:create_request) do - { - name: 'my_app', - lifecycle: { type: 'buildpack', data: { stack: active_stack.name } }, - relationships: { space: { data: { guid: space.guid } } } - } - end - - it 'creates the app without warnings' do - post '/v3/apps', create_request.to_json, user_header - - expect(last_response.status).to eq(201) - expect(parsed_response).not_to have_key('warnings') - expect(last_response.headers['X-Cf-Warnings']).to be_nil - end - end - end - end - - describe 'GET /v3/apps' do - before do - space.organization.add_user(user) - end - - context 'listing all apps' do - let(:api_call) { ->(user_headers) { get '/v3/apps', nil, user_headers } } - let(:space2) { VCAP::CloudController::Space.make(organization: org) } - let(:buildpack_lifecycle) { VCAP::CloudController::BuildpackLifecycleDataModel.make(stack: 'cool-stack', app: app_model1) } - let(:app_model1) { VCAP::CloudController::AppModel.make(guid: 'app1_guid', name: 'name1', space: space) } - let(:app_model2) { VCAP::CloudController::AppModel.make(guid: 'app2_guid', name: 'name2', space: space2) } - - let(:app_model1_response_object) do - { - guid: app_model1.guid, - created_at: iso8601, - updated_at: iso8601, - name: app_model1.name, - state: 'STOPPED', - lifecycle: { - type: 'buildpack', - data: { buildpacks: [], stack: app_model1.lifecycle_data.stack } - }, - relationships: { - space: { data: { guid: space.guid } }, - current_droplet: { data: { guid: nil } } - }, - metadata: { - labels: {}, - annotations: {} - }, - links: { - self: { href: "#{link_prefix}/v3/apps/app1_guid" }, - environment_variables: { href: "#{link_prefix}/v3/apps/app1_guid/environment_variables" }, - space: { href: "#{link_prefix}/v3/spaces/#{space.guid}" }, - processes: { href: "#{link_prefix}/v3/apps/app1_guid/processes" }, - packages: { href: "#{link_prefix}/v3/apps/app1_guid/packages" }, - current_droplet: { href: "#{link_prefix}/v3/apps/app1_guid/droplets/current" }, - droplets: { href: "#{link_prefix}/v3/apps/app1_guid/droplets" }, - tasks: { href: "#{link_prefix}/v3/apps/app1_guid/tasks" }, - start: { href: "#{link_prefix}/v3/apps/app1_guid/actions/start", method: 'POST' }, - stop: { href: "#{link_prefix}/v3/apps/app1_guid/actions/stop", method: 'POST' }, - clear_buildpack_cache: { href: "#{link_prefix}/v3/apps/app1_guid/actions/clear_buildpack_cache", method: 'POST' }, - revisions: { href: "#{link_prefix}/v3/apps/app1_guid/revisions" }, - deployed_revisions: { href: "#{link_prefix}/v3/apps/app1_guid/revisions/deployed" }, - features: { href: "#{link_prefix}/v3/apps/app1_guid/features" } - } - } - end - - let(:app_model2_response_object) do - { - guid: app_model2.guid, - created_at: iso8601, - updated_at: iso8601, - name: app_model2.name, - state: 'STOPPED', - lifecycle: { - type: 'buildpack', - data: { buildpacks: [], stack: app_model2.lifecycle_data.stack } - }, - relationships: { - space: { data: { guid: space2.guid } }, - current_droplet: { data: { guid: nil } } - }, - metadata: { - labels: {}, - annotations: {} - }, - links: { - self: { href: "#{link_prefix}/v3/apps/app2_guid" }, - environment_variables: { href: "#{link_prefix}/v3/apps/app2_guid/environment_variables" }, - space: { href: "#{link_prefix}/v3/spaces/#{space2.guid}" }, - processes: { href: "#{link_prefix}/v3/apps/app2_guid/processes" }, - packages: { href: "#{link_prefix}/v3/apps/app2_guid/packages" }, - current_droplet: { href: "#{link_prefix}/v3/apps/app2_guid/droplets/current" }, - droplets: { href: "#{link_prefix}/v3/apps/app2_guid/droplets" }, - tasks: { href: "#{link_prefix}/v3/apps/app2_guid/tasks" }, - start: { href: "#{link_prefix}/v3/apps/app2_guid/actions/start", method: 'POST' }, - stop: { href: "#{link_prefix}/v3/apps/app2_guid/actions/stop", method: 'POST' }, - clear_buildpack_cache: { href: "#{link_prefix}/v3/apps/app2_guid/actions/clear_buildpack_cache", method: 'POST' }, - revisions: { href: "#{link_prefix}/v3/apps/app2_guid/revisions" }, - deployed_revisions: { href: "#{link_prefix}/v3/apps/app2_guid/revisions/deployed" }, - features: { href: "#{link_prefix}/v3/apps/app2_guid/features" } - } - } - end - - let(:expected_codes_and_responses) do - h = Hash.new({ code: 200, response_objects: [app_model1_response_object, app_model2_response_object] }.freeze) - - h['org_auditor'] = { - code: 200, - response_objects: [] - } - - h['org_billing_manager'] = { - code: 200, - response_objects: [] - } - - h['space_manager'] = { - code: 200, - response_objects: [ - app_model1_response_object - ] - } - - h['space_auditor'] = { - code: 200, - response_objects: [ - app_model1_response_object - ] - } - - h['space_developer'] = { - code: 200, - response_objects: [ - app_model1_response_object - ] - } - - h['space_supporter'] = { - code: 200, - response_objects: [ - app_model1_response_object - ] - } - - h['no_role'] = { code: 200, response_objects: [] } - h - end - - it_behaves_like 'permissions for list endpoint', ALL_PERMISSIONS - end - - describe 'query list parameters' do - it_behaves_like 'list query endpoint' do - let(:request) { 'v3/apps' } - - let(:message) { VCAP::CloudController::AppsListMessage } - - let(:params) do - { - page: '2', - per_page: '10', - order_by: 'updated_at', - names: 'foo', - guids: 'foo', - organization_guids: 'foo', - space_guids: 'foo', - stacks: 'cf', - include: 'space', - lifecycle_type: 'buildpack', - label_selector: 'foo,bar', - created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", - updated_ats: { gt: Time.now.utc.iso8601 } - } - end - - let!(:app_model) { VCAP::CloudController::AppModel.make } - end - end - - context 'pagination' do - before do - space.add_developer(user) - end - - it 'returns a paginated list of apps the user has access to' do - buildpack = VCAP::CloudController::Buildpack.make(name: 'bp-name') - stack = VCAP::CloudController::Stack.make(name: 'stack-name') - - app_model1 = VCAP::CloudController::AppModel.make(name: 'name1', space: space, desired_state: 'STOPPED') - app_model1.lifecycle_data.update( - buildpacks: [buildpack.name], - stack: stack.name - ) - - app_model2 = VCAP::CloudController::AppModel.make( - :docker, - name: 'name2', - space: space, - desired_state: 'STARTED' - ) - VCAP::CloudController::AppModel.make(space:) - VCAP::CloudController::AppModel.make - - get '/v3/apps?per_page=2&include=space', nil, user_header - expect(last_response.status).to eq(200) - - parsed_response = Oj.load(last_response.body) - expect(parsed_response).to be_a_response_like( - { - 'pagination' => { - 'total_results' => 3, - 'total_pages' => 2, - 'first' => { 'href' => "#{link_prefix}/v3/apps?include=space&page=1&per_page=2" }, - 'last' => { 'href' => "#{link_prefix}/v3/apps?include=space&page=2&per_page=2" }, - 'next' => { 'href' => "#{link_prefix}/v3/apps?include=space&page=2&per_page=2" }, - 'previous' => nil - }, - 'resources' => [ - { - 'guid' => app_model1.guid, - 'name' => 'name1', - 'state' => 'STOPPED', - 'lifecycle' => { - 'type' => 'buildpack', - 'data' => { - 'buildpacks' => ['bp-name'], - 'stack' => 'stack-name' - } - }, - 'relationships' => { - 'space' => { - 'data' => { - 'guid' => space.guid - } - }, - 'current_droplet' => { - 'data' => { - 'guid' => nil - } - } - }, - 'created_at' => iso8601, - 'updated_at' => iso8601, - 'metadata' => { 'labels' => {}, 'annotations' => {} }, - 'links' => { - 'self' => { 'href' => "#{link_prefix}/v3/apps/#{app_model1.guid}" }, - 'processes' => { 'href' => "#{link_prefix}/v3/apps/#{app_model1.guid}/processes" }, - 'packages' => { 'href' => "#{link_prefix}/v3/apps/#{app_model1.guid}/packages" }, - 'environment_variables' => { 'href' => "#{link_prefix}/v3/apps/#{app_model1.guid}/environment_variables" }, - 'space' => { 'href' => "#{link_prefix}/v3/spaces/#{space.guid}" }, - 'current_droplet' => { 'href' => "#{link_prefix}/v3/apps/#{app_model1.guid}/droplets/current" }, - 'droplets' => { 'href' => "#{link_prefix}/v3/apps/#{app_model1.guid}/droplets" }, - 'tasks' => { 'href' => "#{link_prefix}/v3/apps/#{app_model1.guid}/tasks" }, - 'start' => { 'href' => "#{link_prefix}/v3/apps/#{app_model1.guid}/actions/start", 'method' => 'POST' }, - 'stop' => { 'href' => "#{link_prefix}/v3/apps/#{app_model1.guid}/actions/stop", 'method' => 'POST' }, - 'clear_buildpack_cache' => { 'href' => "#{link_prefix}/v3/apps/#{app_model1.guid}/actions/clear_buildpack_cache", 'method' => 'POST' }, - 'revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model1.guid}/revisions" }, - 'deployed_revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model1.guid}/revisions/deployed" }, - 'features' => { 'href' => "#{link_prefix}/v3/apps/#{app_model1.guid}/features" } - } - }, - { - 'guid' => app_model2.guid, - 'name' => 'name2', - 'state' => 'STARTED', - 'lifecycle' => { - 'type' => 'docker', - 'data' => {} - }, - 'relationships' => { - 'space' => { - 'data' => { - 'guid' => space.guid - } - }, - 'current_droplet' => { - 'data' => { - 'guid' => nil - } - } - }, - 'created_at' => iso8601, - 'updated_at' => iso8601, - 'metadata' => { 'labels' => {}, 'annotations' => {} }, - 'links' => { - 'self' => { 'href' => "#{link_prefix}/v3/apps/#{app_model2.guid}" }, - 'processes' => { 'href' => "#{link_prefix}/v3/apps/#{app_model2.guid}/processes" }, - 'packages' => { 'href' => "#{link_prefix}/v3/apps/#{app_model2.guid}/packages" }, - 'environment_variables' => { 'href' => "#{link_prefix}/v3/apps/#{app_model2.guid}/environment_variables" }, - 'space' => { 'href' => "#{link_prefix}/v3/spaces/#{space.guid}" }, - 'current_droplet' => { 'href' => "#{link_prefix}/v3/apps/#{app_model2.guid}/droplets/current" }, - 'droplets' => { 'href' => "#{link_prefix}/v3/apps/#{app_model2.guid}/droplets" }, - 'tasks' => { 'href' => "#{link_prefix}/v3/apps/#{app_model2.guid}/tasks" }, - 'start' => { 'href' => "#{link_prefix}/v3/apps/#{app_model2.guid}/actions/start", 'method' => 'POST' }, - 'stop' => { 'href' => "#{link_prefix}/v3/apps/#{app_model2.guid}/actions/stop", 'method' => 'POST' }, - 'clear_buildpack_cache' => { 'href' => "#{link_prefix}/v3/apps/#{app_model2.guid}/actions/clear_buildpack_cache", 'method' => 'POST' }, - 'revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model2.guid}/revisions" }, - 'deployed_revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model2.guid}/revisions/deployed" }, - 'features' => { 'href' => "#{link_prefix}/v3/apps/#{app_model2.guid}/features" } - } - } - ], - 'included' => { - 'spaces' => [{ - 'guid' => space.guid, - 'created_at' => iso8601, - 'updated_at' => iso8601, - 'name' => space.name, - 'relationships' => { - 'organization' => { - 'data' => { - 'guid' => space.organization.guid - } - }, - 'quota' => { - 'data' => nil - } - }, - 'metadata' => { - 'labels' => {}, - 'annotations' => {} - }, - 'links' => { - 'self' => { - 'href' => "#{link_prefix}/v3/spaces/#{space.guid}" - }, - 'organization' => { - 'href' => "#{link_prefix}/v3/organizations/#{space.organization.guid}" - }, - 'features' => { 'href' => %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}/features} }, - 'apply_manifest' => { - 'href' => "#{link_prefix}/v3/spaces/#{space.guid}/actions/apply_manifest", - 'method' => 'POST' - } - } - }] - } - } - ) - end - end - - context 'filtering by timestamps' do - before do - VCAP::CloudController::AppModel.plugin :timestamps, update_on_create: false - end - - # .make updates the resource after creating it, over writing our passed in updated_at timestamp - # Therefore we cannot use shared_examples as the updated_at will not be as written - let!(:resource_1) { VCAP::CloudController::AppModel.create(name: '1', created_at: '2020-05-26T18:47:01Z', updated_at: '2020-05-26T18:47:01Z', space: space) } - let!(:resource_2) { VCAP::CloudController::AppModel.create(name: '2', created_at: '2020-05-26T18:47:02Z', updated_at: '2020-05-26T18:47:02Z', space: space) } - let!(:resource_3) { VCAP::CloudController::AppModel.create(name: '3', created_at: '2020-05-26T18:47:03Z', updated_at: '2020-05-26T18:47:03Z', space: space) } - let!(:resource_4) { VCAP::CloudController::AppModel.create(name: '4', created_at: '2020-05-26T18:47:04Z', updated_at: '2020-05-26T18:47:04Z', space: space) } - - after do - VCAP::CloudController::AppModel.plugin :timestamps, update_on_create: true - end - - it 'filters by the created at' do - get "/v3/apps?created_ats[lt]=#{resource_3.created_at.iso8601}", nil, admin_header - - expect(last_response).to have_status_code(200) - expect(parsed_response['resources'].pluck('guid')).to contain_exactly(resource_1.guid, resource_2.guid) - end - - it 'filters ny the updated_at' do - get "/v3/apps?updated_ats[lt]=#{resource_3.updated_at.iso8601}", nil, admin_header - - expect(last_response).to have_status_code(200) - expect(parsed_response['resources'].pluck('guid')).to contain_exactly(resource_1.guid, resource_2.guid) - end - end - - context 'faceted search' do - let(:admin_header) { headers_for(user, scopes: %w[cloud_controller.admin]) } - - it 'filters by guids' do - app_model1 = VCAP::CloudController::AppModel.make(name: 'name1') - VCAP::CloudController::AppModel.make(name: 'name2') - app_model3 = VCAP::CloudController::AppModel.make(name: 'name3') - - get "/v3/apps?guids=#{app_model1.guid}%2C#{app_model3.guid}", nil, admin_header - - expected_pagination = { - 'total_results' => 2, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/apps?guids=#{app_model1.guid}%2C#{app_model3.guid}&page=1&per_page=50" }, - 'last' => { 'href' => "#{link_prefix}/v3/apps?guids=#{app_model1.guid}%2C#{app_model3.guid}&page=1&per_page=50" }, - 'next' => nil, - 'previous' => nil - } - - parsed_response = Oj.load(last_response.body) - - expect(last_response.status).to eq(200) - expect(parsed_response['resources'].pluck('name')).to eq(%w[name1 name3]) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - - it 'filters by names' do - VCAP::CloudController::AppModel.make(name: 'name1') - VCAP::CloudController::AppModel.make(name: 'name2') - VCAP::CloudController::AppModel.make(name: 'name3') - - get '/v3/apps?names=name1%2Cname2', nil, admin_header - - expected_pagination = { - 'total_results' => 2, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/apps?names=name1%2Cname2&page=1&per_page=50" }, - 'last' => { 'href' => "#{link_prefix}/v3/apps?names=name1%2Cname2&page=1&per_page=50" }, - 'next' => nil, - 'previous' => nil - } - - parsed_response = Oj.load(last_response.body) - - expect(last_response.status).to eq(200) - expect(parsed_response['resources'].pluck('name')).to eq(%w[name1 name2]) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - - it 'filters by organizations' do - app_model1 = VCAP::CloudController::AppModel.make(name: 'name1') - VCAP::CloudController::AppModel.make(name: 'name2') - app_model3 = VCAP::CloudController::AppModel.make(name: 'name3') - - get "/v3/apps?organization_guids=#{app_model1.organization.guid}%2C#{app_model3.organization.guid}", nil, admin_header - - expected_pagination = { - 'total_results' => 2, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/apps?organization_guids=#{app_model1.organization.guid}%2C#{app_model3.organization.guid}&page=1&per_page=50" }, - 'last' => { 'href' => "#{link_prefix}/v3/apps?organization_guids=#{app_model1.organization.guid}%2C#{app_model3.organization.guid}&page=1&per_page=50" }, - 'next' => nil, - 'previous' => nil - } - - parsed_response = Oj.load(last_response.body) - - expect(last_response.status).to eq(200) - expect(parsed_response['resources'].pluck('name')).to eq(%w[name1 name3]) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - - it 'filters by spaces' do - app_model1 = VCAP::CloudController::AppModel.make(name: 'name1') - VCAP::CloudController::AppModel.make(name: 'name2') - app_model3 = VCAP::CloudController::AppModel.make(name: 'name3') - - get "/v3/apps?space_guids=#{app_model1.space.guid}%2C#{app_model3.space.guid}", nil, admin_header - - expected_pagination = { - 'total_results' => 2, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/apps?page=1&per_page=50&space_guids=#{app_model1.space.guid}%2C#{app_model3.space.guid}" }, - 'last' => { 'href' => "#{link_prefix}/v3/apps?page=1&per_page=50&space_guids=#{app_model1.space.guid}%2C#{app_model3.space.guid}" }, - 'next' => nil, - 'previous' => nil - } - - parsed_response = Oj.load(last_response.body) - - expect(last_response.status).to eq(200) - expect(parsed_response['resources'].pluck('name')).to eq(%w[name1 name3]) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - - it 'filters by stack names' do - app_model1 = VCAP::CloudController::AppModel.make(name: 'name1') - app_model2 = VCAP::CloudController::AppModel.make(name: 'name2') - app_model3 = VCAP::CloudController::AppModel.make(name: 'name3') - - stack2 = VCAP::CloudController::Stack.make(name: 'name2') - stack3 = VCAP::CloudController::Stack.make(name: 'name3') - - app_model1.lifecycle_data.stack = stack2.name - app_model1.lifecycle_data.save - - app_model2.lifecycle_data.stack = stack2.name - app_model2.lifecycle_data.save - - app_model3.lifecycle_data.stack = stack3.name - app_model3.lifecycle_data.save - - get "/v3/apps?stacks=#{stack2.name}", nil, admin_header - - expected_pagination = { - 'total_results' => 2, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/apps?page=1&per_page=50&stacks=#{stack2.name}" }, - 'last' => { 'href' => "#{link_prefix}/v3/apps?page=1&per_page=50&stacks=#{stack2.name}" }, - 'next' => nil, - 'previous' => nil - } - - parsed_response = Oj.load(last_response.body) - - expect(last_response.status).to eq(200) - expect(parsed_response['resources'].pluck('name')).to eq(%w[name1 name2]) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - - it 'filters by null stacks' do - app_model1 = VCAP::CloudController::AppModel.make(name: 'name1') - app_model2 = VCAP::CloudController::AppModel.make(name: 'name2') - app_model3 = VCAP::CloudController::AppModel.make(name: 'name3') - - stack2 = VCAP::CloudController::Stack.make(name: 'name2') - stack3 = VCAP::CloudController::Stack.make(name: 'name3') - - app_model1.lifecycle_data.stack = nil - app_model1.lifecycle_data.save - - app_model2.lifecycle_data.stack = stack2.name - app_model2.lifecycle_data.save - - app_model3.lifecycle_data.stack = stack3.name - app_model3.lifecycle_data.save - - get '/v3/apps?stacks=', nil, admin_header - - expected_pagination = { - 'total_results' => 1, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/apps?page=1&per_page=50&stacks=" }, - 'last' => { 'href' => "#{link_prefix}/v3/apps?page=1&per_page=50&stacks=" }, - 'next' => nil, - 'previous' => nil - } - - parsed_response = Oj.load(last_response.body) - - expect(last_response.status).to eq(200) - expect(parsed_response['resources'].pluck('name')).to eq(['name1']) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - - it 'filters by lifecycle_type' do - VCAP::CloudController::AppModel.make(name: 'name1') - docker_app_model = VCAP::CloudController::AppModel.make(name: 'name2') - VCAP::CloudController::AppModel.make(name: 'name3') - - docker_app_model.buildpack_lifecycle_data = nil - docker_app_model.save - - get '/v3/apps?lifecycle_type=buildpack', nil, admin_header - - expected_pagination = { - 'total_results' => 2, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/apps?lifecycle_type=buildpack&page=1&per_page=50" }, - 'last' => { 'href' => "#{link_prefix}/v3/apps?lifecycle_type=buildpack&page=1&per_page=50" }, - 'next' => nil, - 'previous' => nil - } - - parsed_response = Oj.load(last_response.body) - - expect(last_response.status).to eq(200) - expect(parsed_response['resources'].pluck('name')).to eq(%w[name1 name3]) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - end - - context 'ordering' do - before do - space.add_developer(user) - end - - it 'can order by name' do - VCAP::CloudController::AppModel.make(space: space, name: 'zed') - VCAP::CloudController::AppModel.make(space: space, name: 'alpha') - VCAP::CloudController::AppModel.make(space: space, name: 'gamma') - VCAP::CloudController::AppModel.make(space: space, name: 'delta') - VCAP::CloudController::AppModel.make(space: space, name: 'theta') - - ascending = %w[alpha delta gamma theta zed] - descending = ascending.reverse - - # ASCENDING - get '/v3/apps?order_by=name', nil, user_header - expect(last_response.status).to eq(200) - parsed_response = Oj.load(last_response.body) - app_names = parsed_response['resources'].pluck('name') - expect(app_names).to eq(ascending) - expect(parsed_response['pagination']['first']['href']).to include("order_by=#{CGI.escape('+')}name") - - # DESCENDING - get '/v3/apps?order_by=-name', nil, user_header - expect(last_response.status).to eq(200) - parsed_response = Oj.load(last_response.body) - app_names = parsed_response['resources'].pluck('name') - expect(app_names).to eq(descending) - expect(parsed_response['pagination']['first']['href']).to include('order_by=-name') - end - - it 'can order by state' do - VCAP::CloudController::AppModel.make(space: space, desired_state: 'STARTED') - VCAP::CloudController::AppModel.make(space: space, desired_state: 'STOPPED') - VCAP::CloudController::AppModel.make(space: space, desired_state: 'STARTED') - VCAP::CloudController::AppModel.make(space: space, desired_state: 'STOPPED') - ascending = %w[STARTED STARTED STOPPED STOPPED] - descending = ascending.reverse - - # ASCENDING - get '/v3/apps?order_by=state', nil, user_header - expect(last_response.status).to eq(200) - parsed_response = Oj.load(last_response.body) - app_states = parsed_response['resources'].pluck('state') - expect(app_states).to eq(ascending) - expect(parsed_response['pagination']['first']['href']).to include("order_by=#{CGI.escape('+')}state") - - # DESCENDING - get '/v3/apps?order_by=-state', nil, user_header - expect(last_response.status).to eq(200) - parsed_response = Oj.load(last_response.body) - app_states = parsed_response['resources'].pluck('state') - expect(app_states).to eq(descending) - expect(parsed_response['pagination']['first']['href']).to include('order_by=-state') - end - end - - context 'labels' do - let!(:app1) { VCAP::CloudController::AppModel.make(name: 'name1') } - let!(:app1_label) { VCAP::CloudController::AppLabelModel.make(resource_guid: app1.guid, key_name: 'foo', value: 'bar') } - - let!(:app2) { VCAP::CloudController::AppModel.make(name: 'name2') } - let!(:app2_label) { VCAP::CloudController::AppLabelModel.make(resource_guid: app2.guid, key_name: 'foo', value: 'funky') } - let!(:app2__exclusive_label) { VCAP::CloudController::AppLabelModel.make(resource_guid: app2.guid, key_name: 'santa', value: 'claus') } - - let(:admin_header) { headers_for(user, scopes: %w[cloud_controller.admin]) } - - it 'returns a 200 and the filtered apps for "in" label selector' do - get '/v3/apps?label_selector=foo in (bar)', nil, admin_header - - parsed_response = Oj.load(last_response.body) - - expected_pagination = { - 'total_results' => 1, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/apps?label_selector=foo+in+%28bar%29&page=1&per_page=50" }, - 'last' => { 'href' => "#{link_prefix}/v3/apps?label_selector=foo+in+%28bar%29&page=1&per_page=50" }, - 'next' => nil, - 'previous' => nil - } - - expect(last_response.status).to eq(200) - expect(parsed_response['resources'].pluck('guid')).to contain_exactly(app1.guid) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - - it 'returns a 200 and the filtered apps for "notin" label selector' do - get '/v3/apps?label_selector=foo notin (bar)', nil, admin_header - - parsed_response = Oj.load(last_response.body) - - expected_pagination = { - 'total_results' => 1, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/apps?label_selector=foo+notin+%28bar%29&page=1&per_page=50" }, - 'last' => { 'href' => "#{link_prefix}/v3/apps?label_selector=foo+notin+%28bar%29&page=1&per_page=50" }, - 'next' => nil, - 'previous' => nil - } - - expect(last_response.status).to eq(200) - expect(parsed_response['resources'].pluck('guid')).to contain_exactly(app2.guid) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - - it 'returns a 200 and the filtered apps for "=" label selector' do - get '/v3/apps?label_selector=foo=bar', nil, admin_header - - parsed_response = Oj.load(last_response.body) - - expected_pagination = { - 'total_results' => 1, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/apps?label_selector=foo%3Dbar&page=1&per_page=50" }, - 'last' => { 'href' => "#{link_prefix}/v3/apps?label_selector=foo%3Dbar&page=1&per_page=50" }, - 'next' => nil, - 'previous' => nil - } - - expect(last_response.status).to eq(200) - expect(parsed_response['resources'].pluck('guid')).to contain_exactly(app1.guid) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - - it 'returns a 200 and the filtered apps for "==" label selector' do - get '/v3/apps?label_selector=foo==bar', nil, admin_header - - parsed_response = Oj.load(last_response.body) - - expected_pagination = { - 'total_results' => 1, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/apps?label_selector=foo%3D%3Dbar&page=1&per_page=50" }, - 'last' => { 'href' => "#{link_prefix}/v3/apps?label_selector=foo%3D%3Dbar&page=1&per_page=50" }, - 'next' => nil, - 'previous' => nil - } - - expect(last_response.status).to eq(200) - expect(parsed_response['resources'].pluck('guid')).to contain_exactly(app1.guid) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - - it 'returns a 200 and the filtered apps for "!=" label selector' do - get '/v3/apps?label_selector=foo!=bar', nil, admin_header - - parsed_response = Oj.load(last_response.body) - - expected_pagination = { - 'total_results' => 1, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/apps?label_selector=foo%21%3Dbar&page=1&per_page=50" }, - 'last' => { 'href' => "#{link_prefix}/v3/apps?label_selector=foo%21%3Dbar&page=1&per_page=50" }, - 'next' => nil, - 'previous' => nil - } - - expect(last_response.status).to eq(200) - expect(parsed_response['resources'].pluck('guid')).to contain_exactly(app2.guid) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - - it 'returns a 200 and the filtered apps for "==" label selector' do - get '/v3/apps?label_selector=foo=funky,santa=claus', nil, admin_header - - parsed_response = Oj.load(last_response.body) - - expected_pagination = { - 'total_results' => 1, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/apps?label_selector=foo%3Dfunky%2Csanta%3Dclaus&page=1&per_page=50" }, - 'last' => { 'href' => "#{link_prefix}/v3/apps?label_selector=foo%3Dfunky%2Csanta%3Dclaus&page=1&per_page=50" }, - 'next' => nil, - 'previous' => nil - } - - expect(last_response.status).to eq(200) - expect(parsed_response['resources'].pluck('guid')).to contain_exactly(app2.guid) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - - it 'returns a 200 and the filtered apps for existence label selector' do - get '/v3/apps?label_selector=santa', nil, admin_header - - parsed_response = Oj.load(last_response.body) - - expected_pagination = { - 'total_results' => 1, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/apps?label_selector=santa&page=1&per_page=50" }, - 'last' => { 'href' => "#{link_prefix}/v3/apps?label_selector=santa&page=1&per_page=50" }, - 'next' => nil, - 'previous' => nil - } - - expect(last_response.status).to eq(200) - expect(parsed_response['resources'].pluck('guid')).to contain_exactly(app2.guid) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - - it 'returns a 200 and the filtered apps for non-existence label selector' do - get '/v3/apps?label_selector=!santa', nil, admin_header - - parsed_response = Oj.load(last_response.body) - - expected_pagination = { - 'total_results' => 1, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/apps?label_selector=%21santa&page=1&per_page=50" }, - 'last' => { 'href' => "#{link_prefix}/v3/apps?label_selector=%21santa&page=1&per_page=50" }, - 'next' => nil, - 'previous' => nil - } - - expect(last_response.status).to eq(200) - expect(parsed_response['resources'].pluck('guid')).to contain_exactly(app1.guid) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - end - - context 'labels and existing filters' do - let!(:space1) { VCAP::CloudController::Space.make } - let!(:space2) { VCAP::CloudController::Space.make } - let!(:app1) { VCAP::CloudController::AppModel.make(name: 'name1', space: space1) } - let!(:app2) { VCAP::CloudController::AppModel.make(name: 'name2', space: space2) } - let!(:app3) { VCAP::CloudController::AppModel.make(name: 'name3', space: space2) } - let!(:app1_label1) { VCAP::CloudController::AppLabelModel.make(resource_guid: app1.guid, key_name: 'foo', value: 'funky') } - let!(:app2_label1) { VCAP::CloudController::AppLabelModel.make(resource_guid: app2.guid, key_name: 'foo', value: 'funky') } - let!(:app2_label2) { VCAP::CloudController::AppLabelModel.make(resource_guid: app2.guid, key_name: 'fruit', value: 'strawberry') } - let!(:app3_label1) { VCAP::CloudController::AppLabelModel.make(resource_guid: app3.guid, key_name: 'fruit', value: 'strawberry') } - - let(:admin_header) { headers_for(user, scopes: %w[cloud_controller.admin]) } - - it 'returns a 200 and the correct app when querying with space guid' do - get "/v3/apps?space_guids=#{space2.guid}&label_selector=foo==funky", nil, admin_header - - parsed_response = Oj.load(last_response.body) - - expected_pagination = { - 'total_results' => 1, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/apps?label_selector=foo%3D%3Dfunky&page=1&per_page=50&space_guids=#{space2.guid}" }, - 'last' => { 'href' => "#{link_prefix}/v3/apps?label_selector=foo%3D%3Dfunky&page=1&per_page=50&space_guids=#{space2.guid}" }, - 'next' => nil, - 'previous' => nil - } - - expect(last_response.status).to eq(200) - expect(parsed_response['resources'].pluck('guid')).to contain_exactly(app2.guid) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - - it 'returns a 200 and the correct app when querying with space guid' do - get "/v3/apps?space_guids=#{space2.guid}&label_selector=fruit==strawberry&names=name2", nil, admin_header - - parsed_response = Oj.load(last_response.body) - - expected_pagination = { - 'total_results' => 1, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/apps?label_selector=fruit%3D%3Dstrawberry&names=name2&page=1&per_page=50&space_guids=#{space2.guid}" }, - 'last' => { 'href' => "#{link_prefix}/v3/apps?label_selector=fruit%3D%3Dstrawberry&names=name2&page=1&per_page=50&space_guids=#{space2.guid}" }, - 'next' => nil, - 'previous' => nil - } - - expect(last_response.status).to eq(200) - expect(parsed_response['resources'].pluck('guid')).to contain_exactly(app2.guid) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - end - - context 'including orgs and spaces' do - it 'presents the apps listed with the orgs and spaces included' do - VCAP::CloudController::AppModel.make(:docker, name: 'name1', guid: 'app1-guid', space: space) - - org1 = space.organization - org2 = VCAP::CloudController::Organization.make(name: 'org2', guid: 'org2-guid', created_at: 1.day.ago) - space2 = VCAP::CloudController::Space.make(name: 'space2', guid: 'space2-guid', organization: org2) - - unused_org = VCAP::CloudController::Organization.make(name: 'unused_org', guid: 'unused_org-guid') - - VCAP::CloudController::Space.make(name: 'unused_space', guid: 'unused_space-guid', organization: unused_org) - - VCAP::CloudController::AppModel.make( - :docker, - name: 'name2', - guid: 'app2-guid', - space: space2 - ) - - get '/v3/apps?per_page=2&include=space,space.organization', nil, admin_header - expect(last_response.status).to eq(200) - - parsed_response = Oj.load(last_response.body) - - expect(parsed_response['included']['organizations'][0]).to be_a_response_like({ - 'guid' => org1.guid, - 'created_at' => iso8601, - 'updated_at' => iso8601, - 'name' => org1.name, - 'metadata' => { - 'labels' => {}, - 'annotations' => {} - }, - 'suspended' => false, - 'links' => { - 'self' => { - 'href' => "#{link_prefix}/v3/organizations/#{org1.guid}" - }, - 'default_domain' => { - 'href' => "#{link_prefix}/v3/organizations/#{org1.guid}/domains/default" - }, - 'domains' => { - 'href' => "#{link_prefix}/v3/organizations/#{org1.guid}/domains" - }, - 'quota' => { - 'href' => "#{link_prefix}/v3/organization_quotas/#{org1.quota_definition.guid}" - } - }, - 'relationships' => { 'quota' => { 'data' => { 'guid' => org1.quota_definition.guid } } } - }) - expect(parsed_response['included']['organizations'][1]).to be_a_response_like({ - 'guid' => org2.guid, - 'created_at' => iso8601, - 'updated_at' => iso8601, - 'name' => org2.name, - 'suspended' => false, - 'metadata' => { - 'labels' => {}, - 'annotations' => {} - }, - 'links' => { - 'self' => { - 'href' => "#{link_prefix}/v3/organizations/#{org2.guid}" - }, - 'default_domain' => { - 'href' => "#{link_prefix}/v3/organizations/#{org2.guid}/domains/default" - }, - 'domains' => { - 'href' => "#{link_prefix}/v3/organizations/#{org2.guid}/domains" - }, - 'quota' => { - 'href' => "#{link_prefix}/v3/organization_quotas/#{org2.quota_definition.guid}" - } - }, - 'relationships' => { 'quota' => { 'data' => { 'guid' => org2.quota_definition.guid } } } - }) - end - - it 'flags unsupported includes that contain supported ones' do - get '/v3/apps?per_page=2&include=space.organization,spaceship,borgs,space', nil, admin_header - expect(last_response.status).to eq(400) - end - - it 'does not include spaces if no one asks for them' do - get '/v3/apps', nil, admin_header - parsed_response = Oj.load(last_response.body) - expect(parsed_response).not_to have_key('included') - end - end - - context 'when including orgs' do - before do - VCAP::CloudController::AppModel.make - end - - it 'eagerly loads spaces to efficiently access space.organization_id' do - expect(VCAP::CloudController::IncludeOrganizationDecorator).to receive(:decorate) do |_, resources| - expect(resources).not_to be_empty - resources.each { |r| expect(r.associations).to include(:space) } - end - - get '/v3/apps?include=space.organization', nil, admin_header - expect(last_response).to have_status_code(200) - end - end - end - - describe 'GET /v3/apps/:guid' do - let!(:buildpack) { VCAP::CloudController::Buildpack.make(name: 'bp-name') } - let!(:stack) { VCAP::CloudController::Stack.make(name: 'stack-name') } - let!(:app_model) do - VCAP::CloudController::AppModel.make( - :buildpack, - name: 'my_app', - guid: 'app1_guid', - space: space, - desired_state: 'STARTED', - environment_variables: { 'unicorn' => 'horn' } - ) - end - - before do - space.organization.add_user(user) - app_model.lifecycle_data.buildpacks = [buildpack.name] - app_model.lifecycle_data.stack = stack.name - app_model.lifecycle_data.save - app_model.add_process(VCAP::CloudController::ProcessModel.make(instances: 1)) - app_model.add_process(VCAP::CloudController::ProcessModel.make(instances: 2)) - end - - context 'when getting an app' do - let(:api_call) { ->(user_headers) { get "/v3/apps/#{app_model.guid}", nil, user_headers } } - - let(:app_model_response_object) do - { - guid: app_model.guid, - created_at: iso8601, - updated_at: iso8601, - name: app_model.name, - state: 'STARTED', - lifecycle: { - type: 'buildpack', - data: { buildpacks: [buildpack.name], stack: app_model.lifecycle_data.stack } - }, - relationships: { - space: { data: { guid: space.guid } }, - current_droplet: { data: { guid: app_model.droplet_guid } } - }, - metadata: { - labels: {}, - annotations: {} - }, - links: { - self: { href: "#{link_prefix}/v3/apps/app1_guid" }, - environment_variables: { href: "#{link_prefix}/v3/apps/app1_guid/environment_variables" }, - space: { href: "#{link_prefix}/v3/spaces/#{space.guid}" }, - processes: { href: "#{link_prefix}/v3/apps/app1_guid/processes" }, - packages: { href: "#{link_prefix}/v3/apps/app1_guid/packages" }, - current_droplet: { href: "#{link_prefix}/v3/apps/app1_guid/droplets/current" }, - droplets: { href: "#{link_prefix}/v3/apps/app1_guid/droplets" }, - tasks: { href: "#{link_prefix}/v3/apps/app1_guid/tasks" }, - start: { href: "#{link_prefix}/v3/apps/app1_guid/actions/start", method: 'POST' }, - stop: { href: "#{link_prefix}/v3/apps/app1_guid/actions/stop", method: 'POST' }, - clear_buildpack_cache: { href: "#{link_prefix}/v3/apps/app1_guid/actions/clear_buildpack_cache", method: 'POST' }, - revisions: { href: "#{link_prefix}/v3/apps/app1_guid/revisions" }, - deployed_revisions: { href: "#{link_prefix}/v3/apps/app1_guid/revisions/deployed" }, - features: { href: "#{link_prefix}/v3/apps/app1_guid/features" } - } - } - end - - let(:expected_codes_and_responses) do - h = Hash.new({ code: 200, response_object: app_model_response_object }.freeze) - h['org_auditor'] = { code: 404 } - h['org_billing_manager'] = { code: 404 } - h['no_role'] = { code: 404 } - h - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - - context 'when the user has permission to view the app' do - before do - space.add_developer(user) - end - - it 'gets a specific app' do - get "/v3/apps/#{app_model.guid}", nil, user_header - expect(last_response.status).to eq(200) - - parsed_response = Oj.load(last_response.body) - expect(parsed_response).to be_a_response_like( - { - 'name' => 'my_app', - 'guid' => app_model.guid, - 'state' => 'STARTED', - 'created_at' => iso8601, - 'updated_at' => iso8601, - 'metadata' => { 'labels' => {}, 'annotations' => {} }, - 'lifecycle' => { - 'type' => 'buildpack', - 'data' => { - 'buildpacks' => ['bp-name'], - 'stack' => 'stack-name' - } - }, - 'relationships' => { - 'space' => { - 'data' => { - 'guid' => space.guid - } - }, - 'current_droplet' => { - 'data' => { - 'guid' => app_model.droplet_guid - } - } - }, - 'links' => { - 'self' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}" }, - 'processes' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/processes" }, - 'packages' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/packages" }, - 'environment_variables' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/environment_variables" }, - 'space' => { 'href' => "#{link_prefix}/v3/spaces/#{space.guid}" }, - 'current_droplet' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/droplets/current" }, - 'droplets' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/droplets" }, - 'tasks' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/tasks" }, - 'start' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/start", 'method' => 'POST' }, - 'stop' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/stop", 'method' => 'POST' }, - 'clear_buildpack_cache' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/clear_buildpack_cache", 'method' => 'POST' }, - 'revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/revisions" }, - 'deployed_revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/revisions/deployed" }, - 'features' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/features" } - } - } - ) - end - - it 'gets a specific app including space' do - get "/v3/apps/#{app_model.guid}?include=space", nil, user_header - expect(last_response.status).to eq(200) - - parsed_response = Oj.load(last_response.body) - expect(parsed_response).to be_a_response_like( - { - 'name' => 'my_app', - 'guid' => app_model.guid, - 'state' => 'STARTED', - 'created_at' => iso8601, - 'updated_at' => iso8601, - 'metadata' => { 'labels' => {}, 'annotations' => {} }, - 'lifecycle' => { - 'type' => 'buildpack', - 'data' => { - 'buildpacks' => ['bp-name'], - 'stack' => 'stack-name' - } - }, - 'relationships' => { - 'space' => { - 'data' => { - 'guid' => space.guid - } - }, - 'current_droplet' => { - 'data' => { - 'guid' => app_model.droplet_guid - } - } - }, - 'links' => { - 'self' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}" }, - 'processes' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/processes" }, - 'packages' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/packages" }, - 'environment_variables' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/environment_variables" }, - 'space' => { 'href' => "#{link_prefix}/v3/spaces/#{space.guid}" }, - 'current_droplet' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/droplets/current" }, - 'droplets' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/droplets" }, - 'tasks' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/tasks" }, - 'start' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/start", 'method' => 'POST' }, - 'stop' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/stop", 'method' => 'POST' }, - 'clear_buildpack_cache' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/clear_buildpack_cache", 'method' => 'POST' }, - 'revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/revisions" }, - 'deployed_revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/revisions/deployed" }, - 'features' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/features" } - }, - 'included' => { - 'spaces' => [{ - 'guid' => space.guid, - 'created_at' => iso8601, - 'updated_at' => iso8601, - 'name' => space.name, - 'relationships' => { - 'organization' => { - 'data' => { - 'guid' => space.organization.guid - } - }, - 'quota' => { - 'data' => nil - } - }, - 'metadata' => { - 'labels' => {}, - 'annotations' => {} - }, - 'links' => { - 'self' => { - 'href' => "#{link_prefix}/v3/spaces/#{space.guid}" - }, - 'organization' => { - 'href' => "#{link_prefix}/v3/organizations/#{space.organization.guid}" - }, - 'features' => { 'href' => %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}/features} }, - 'apply_manifest' => { - 'href' => "#{link_prefix}/v3/spaces/#{space.guid}/actions/apply_manifest", - 'method' => 'POST' - } - } - }] - } - } - ) - end - - it 'gets a specific app including space and org' do - get "/v3/apps/#{app_model.guid}?include=space.organization", nil, user_header - expect(last_response.status).to eq(200) - - parsed_response = Oj.load(last_response.body) - spaces = parsed_response['included']['spaces'] - orgs = parsed_response['included']['organizations'] - - expect(spaces).to be_present - expect(orgs[0]).to be_a_response_like( - { - 'guid' => org.guid, - 'created_at' => iso8601, - 'updated_at' => iso8601, - 'name' => org.name, - 'metadata' => { - 'labels' => {}, - 'annotations' => {} - }, - 'suspended' => false, - 'links' => { - 'self' => { - 'href' => "#{link_prefix}/v3/organizations/#{org.guid}" - }, - 'default_domain' => { - 'href' => "#{link_prefix}/v3/organizations/#{org.guid}/domains/default" - }, - 'domains' => { - 'href' => "#{link_prefix}/v3/organizations/#{org.guid}/domains" - }, - 'quota' => { - 'href' => "#{link_prefix}/v3/organization_quotas/#{org.quota_definition.guid}" - } - }, - 'relationships' => { 'quota' => { 'data' => { 'guid' => org.quota_definition.guid } } } - } - ) - end - end - end - - describe 'GET /v3/apps/:guid/env' do - before do - space.organization.add_user(user) - end - - context 'when getting an apps environment variables' do - let(:api_call) { ->(user_headers) { get "/v3/apps/#{app_model.guid}/env", nil, user_headers } } - let!(:app_model) do - VCAP::CloudController::AppModel.make( - :buildpack, - name: 'my_app', - guid: 'app1_guid', - space: space, - environment_variables: { 'unicorn' => 'horn' } - ) - end - - let(:app_model_response_object) do - { - environment_variables: app_model.environment_variables, - staging_env_json: {}, - running_env_json: {}, - system_env_json: { VCAP_SERVICES: {} }, - application_env_json: anything - } - end - let(:app_model_empty_system_env_response_object) do - { - environment_variables: app_model.environment_variables, - staging_env_json: {}, - running_env_json: {}, - system_env_json: { - redacted_message: '[PRIVATE DATA HIDDEN]' - }, - application_env_json: anything - } - end - - let(:expected_codes_and_responses) do - h = Hash.new({ code: 200, response_object: app_model_response_object }.freeze) - h['space_supporter'] = { code: 200, response_object: app_model_empty_system_env_response_object } - h['global_auditor'] = h['org_manager'] = h['space_manager'] = h['space_auditor'] = { code: 403 } - h['org_auditor'] = h['org_billing_manager'] = h['no_role'] = { code: 404 } - h - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - - context 'when k8s service bindings are enabled' do - let(:app_model_response_object) do - r = super() - r[:system_env_json] = { SERVICE_BINDING_ROOT: '/etc/cf-service-bindings' } - r - end - - before do - app_model.update(service_binding_k8s_enabled: true) - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - - context 'when file-based VCAP service bindings are enabled' do - let(:app_model_response_object) do - r = super() - r[:system_env_json] = { VCAP_SERVICES_FILE_PATH: '/etc/cf-service-bindings/vcap_services' } - r - end - - before do - app_model.update(file_based_vcap_services_enabled: true) - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - end - - context 'when VCAP_SERVICES contains potentially sensitive information' do - before do - group = VCAP::CloudController::EnvironmentVariableGroup.staging - group.environment_json = { STAGING_ENV: 'staging_value' } - group.save - - group = VCAP::CloudController::EnvironmentVariableGroup.running - group.environment_json = { RUNNING_ENV: 'running_value' } - group.save - end - - let(:api_call) { ->(user_headers) { get "/v3/apps/#{app_model.guid}/env", nil, user_headers } } - let(:app_model) do - VCAP::CloudController::AppModel.make( - name: 'my_app', - space: space, - environment_variables: { 'unicorn' => 'horn' } - ) - end - let(:service_instance) do - VCAP::CloudController::ManagedServiceInstance.make( - space: space, - name: 'si-name', - tags: ['50% off'] - ) - end - let(:service_binding) do - VCAP::CloudController::ServiceBinding.make( - service_instance: service_instance, - app: app_model, - syslog_drain_url: 'https://syslog.example.com/drain', - credentials: { password: 'top-secret' } - ) - end - let(:expected_response) do - { - 'staging_env_json' => { - 'STAGING_ENV' => 'staging_value' - }, - 'running_env_json' => { - 'RUNNING_ENV' => 'running_value' - }, - 'environment_variables' => { - 'unicorn' => 'horn' - }, - 'system_env_json' => { - 'VCAP_SERVICES' => { - service_instance.service.label => [ - { - 'name' => 'si-name', - 'instance_guid' => service_instance.guid, - 'instance_name' => 'si-name', - 'binding_guid' => service_binding.guid, - 'binding_name' => nil, - 'credentials' => { 'password' => 'top-secret' }, - 'syslog_drain_url' => 'https://syslog.example.com/drain', - 'volume_mounts' => [], - 'label' => service_instance.service.label, - 'provider' => nil, - 'plan' => service_instance.service_plan.name, - 'tags' => ['50% off'] - } - ] - } - }, - 'application_env_json' => { - 'VCAP_APPLICATION' => { - 'cf_api' => "#{TestConfig.config[:external_protocol]}://#{TestConfig.config[:external_domain]}", - 'limits' => { - 'fds' => 16_384 - }, - 'application_name' => 'my_app', - 'application_uris' => [], - 'name' => 'my_app', - 'organization_id' => space.organization.guid, - 'organization_name' => space.organization.name, - 'space_id' => space.guid, - 'space_name' => space.name, - 'uris' => [], - 'users' => nil, - 'application_id' => app_model.guid - } - } - } - end - - let(:expected_response_system_env_redacted) do - { - 'staging_env_json' => { - 'STAGING_ENV' => 'staging_value' - }, - 'running_env_json' => { - 'RUNNING_ENV' => 'running_value' - }, - 'environment_variables' => { - 'unicorn' => 'horn' - }, - 'system_env_json' => { - 'redacted_message' => '[PRIVATE DATA HIDDEN]' - }, - 'application_env_json' => { - 'VCAP_APPLICATION' => { - 'cf_api' => "#{TestConfig.config[:external_protocol]}://#{TestConfig.config[:external_domain]}", - 'limits' => { - 'fds' => 16_384 - }, - 'application_name' => 'my_app', - 'application_uris' => [], - 'name' => 'my_app', - 'organization_id' => space.organization.guid, - 'organization_name' => space.organization.name, - 'space_id' => space.guid, - 'space_name' => space.name, - 'uris' => [], - 'users' => nil, - 'application_id' => app_model.guid - } - } - } - end - - let(:expected_codes_and_responses) do - h = Hash.new({ code: 403 }.freeze) - h['admin'] = h['admin_read_only'] = h['space_developer'] = { code: 200, response_object: expected_response } - h['space_supporter'] = { code: 200, response_object: expected_response_system_env_redacted } - h['org_auditor'] = h['org_billing_manager'] = h['no_role'] = { code: 404 } - h - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - - 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_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS do - let(:expected_codes_and_responses) do - h = Hash.new({ code: 403 }.freeze) - h['admin'] = h['admin_read_only'] = { code: 200, response_object: expected_response } - h['org_auditor'] = h['org_billing_manager'] = h['no_role'] = { code: 404 } - h - end - end - end - end - end - - describe 'GET /v3/apps/:guid/builds' do - let(:app_model) { VCAP::CloudController::AppModel.make(space: space, name: 'my-app') } - let(:build) do - VCAP::CloudController::BuildModel.make( - package: package, - app: app_model, - staging_memory_in_mb: 123, - staging_disk_in_mb: 456, - staging_log_rate_limit: 789, - created_by_user_name: 'bob the builder', - created_by_user_guid: user.guid, - created_by_user_email: 'bob@loblaw.com' - ) - end - let!(:second_build) do - VCAP::CloudController::BuildModel.make( - package: package, - app: app_model, - staging_memory_in_mb: 123, - staging_disk_in_mb: 456, - staging_log_rate_limit: 789, - created_at: build.created_at - 1.day, - created_by_user_name: 'bob the builder', - created_by_user_guid: user.guid, - created_by_user_email: 'bob@loblaw.com' - ) - end - let(:package) { VCAP::CloudController::PackageModel.make(app_guid: app_model.guid) } - let(:droplet) do - VCAP::CloudController::DropletModel.make( - state: VCAP::CloudController::DropletModel::STAGED_STATE, - package_guid: package.guid, - build: build - ) - end - let(:second_droplet) do - VCAP::CloudController::DropletModel.make( - state: VCAP::CloudController::DropletModel::STAGED_STATE, - package_guid: package.guid, - build: second_build - ) - end - let(:body) do - { - lifecycle: { - type: 'buildpack', - data: { - buildpacks: ['http://github.com/myorg/awesome-buildpack'], - stack: 'cflinuxfs4' - } - } - } - end - - describe 'permissions' do - let(:api_call) do - ->(headers) { get "/v3/apps/#{app_model.guid}/builds", nil, headers } - end - let(:expected_codes_and_responses) do - h = Hash.new({ code: 200, response_guids: [build.guid, second_build.guid] }.freeze) - h['org_auditor'] = { code: 404 } - h['org_billing_manager'] = { code: 404 } - h['no_role'] = { code: 404 } - h - end - - it_behaves_like 'permissions for list endpoint', ALL_PERMISSIONS - end - - describe 'as a developer' do - let(:staging_message) { VCAP::CloudController::BuildCreateMessage.new(body) } - let(:per_page) { 2 } - let(:order_by) { '-created_at' } - - before do - space.organization.add_user(user) - space.add_developer(user) - VCAP::CloudController::BuildpackLifecycle.new(package, staging_message).create_lifecycle_data_model(build) - VCAP::CloudController::BuildpackLifecycle.new(package, staging_message).create_lifecycle_data_model(second_build) - build.update(state: droplet.state, error_description: droplet.error_description) - second_build.update(state: second_droplet.state, error_description: second_droplet.error_description) - end - - it 'lists the builds for app' do - get "v3/apps/#{app_model.guid}/builds?order_by=#{order_by}&per_page=#{per_page}", nil, user_header - - parsed_response = Oj.load(last_response.body) - - expect(last_response.status).to eq(200) - expect(parsed_response['resources']).to include(hash_including('guid' => build.guid)) - expect(parsed_response['resources']).to include(hash_including('guid' => second_build.guid)) - expect(parsed_response).to be_a_response_like({ - 'pagination' => { - 'total_results' => 2, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/builds?order_by=#{order_by}&page=1&per_page=2" }, - 'last' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/builds?order_by=#{order_by}&page=1&per_page=2" }, - 'next' => nil, - 'previous' => nil - }, - 'resources' => [ - { - 'guid' => build.guid, - 'created_at' => iso8601, - 'updated_at' => iso8601, - 'state' => 'STAGED', - 'error' => nil, - 'staging_memory_in_mb' => 123, - 'staging_disk_in_mb' => 456, - 'staging_log_rate_limit_bytes_per_second' => 789, - 'lifecycle' => { - 'type' => 'buildpack', - 'data' => { - 'buildpacks' => ['http://github.com/myorg/awesome-buildpack'], - 'stack' => 'cflinuxfs4' - } - }, - 'package' => { 'guid' => package.guid }, - 'droplet' => { - 'guid' => droplet.guid - }, - 'relationships' => { 'app' => { 'data' => { 'guid' => app_model.guid } } }, - 'metadata' => { 'labels' => {}, 'annotations' => {} }, - 'links' => { - 'self' => { 'href' => "#{link_prefix}/v3/builds/#{build.guid}" }, - 'app' => { 'href' => "#{link_prefix}/v3/apps/#{package.app.guid}" }, - 'droplet' => { 'href' => "#{link_prefix}/v3/droplets/#{droplet.guid}" } - }, - 'created_by' => { 'guid' => user.guid, 'name' => 'bob the builder', 'email' => 'bob@loblaw.com' } - }, - { - 'guid' => second_build.guid, - 'created_at' => iso8601, - 'updated_at' => iso8601, - 'state' => 'STAGED', - 'error' => nil, - 'staging_memory_in_mb' => 123, - 'staging_disk_in_mb' => 456, - 'staging_log_rate_limit_bytes_per_second' => 789, - 'lifecycle' => { - 'type' => 'buildpack', - 'data' => { - 'buildpacks' => ['http://github.com/myorg/awesome-buildpack'], - 'stack' => 'cflinuxfs4' - } - }, - 'package' => { 'guid' => package.guid }, - 'droplet' => { - 'guid' => second_droplet.guid - }, - 'relationships' => { 'app' => { 'data' => { 'guid' => app_model.guid } } }, - 'metadata' => { 'labels' => {}, 'annotations' => {} }, - 'links' => { - 'self' => { 'href' => "#{link_prefix}/v3/builds/#{second_build.guid}" }, - 'app' => { 'href' => "#{link_prefix}/v3/apps/#{package.app.guid}" }, - 'droplet' => { 'href' => "#{link_prefix}/v3/droplets/#{second_droplet.guid}" } - }, - 'created_by' => { 'guid' => user.guid, 'name' => 'bob the builder', 'email' => 'bob@loblaw.com' } - } - ] - }) - end - - it_behaves_like 'list_endpoint_with_common_filters' do - let(:resource_klass) { VCAP::CloudController::BuildModel } - let(:additional_resource_params) { { app: app_model } } - let(:api_call) do - ->(headers, filters) { get "/v3/apps/#{app_model.guid}/builds?#{filters}", nil, headers } - end - let(:headers) { admin_header } - end - - it 'filters on label_selector' do - VCAP::CloudController::BuildLabelModel.make(key_name: 'fruit', value: 'strawberry', build: build) - - get "/v3/apps/#{app_model.guid}/builds?label_selector=fruit=strawberry", {}, user_header - - expect(last_response.status).to eq(200) - expect(parsed_response['resources'].count).to eq(1) - expect(parsed_response['resources'][0]['guid']).to eq(build.guid) - end - end - end - - describe 'GET /v3/apps/:guid/ssh_enabled' do - before do - space.organization.add_user(user) - end - - context 'when getting an apps ssh_enabled value' do - let(:api_call) { ->(user_headers) { get "/v3/apps/#{app_model.guid}/ssh_enabled", nil, user_headers } } - let!(:app_model) do - VCAP::CloudController::AppModel.make( - :buildpack, - name: 'my_app', - guid: 'app1_guid', - space: space - ) - end - - let(:expected_codes_and_responses) do - h = Hash.new({ code: 200 }.freeze) - h['org_auditor'] = { code: 404 } - h['org_billing_manager'] = { code: 404 } - h['no_role'] = { code: 404 } - h - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - end - - describe 'DELETE /v3/apps/guid' do - let!(:app_model) { VCAP::CloudController::AppModel.make(name: 'app_name', space: space) } - let!(:package) { VCAP::CloudController::PackageModel.make(app: app_model) } - let!(:droplet) { VCAP::CloudController::DropletModel.make(package: package, app: app_model) } - let!(:process) { VCAP::CloudController::ProcessModel.make(app: app_model) } - let!(:deployment) { VCAP::CloudController::DeploymentModel.make(app: app_model) } - let(:user_email) { nil } - - it 'deletes an App' do - space.organization.add_user(user) - space.add_developer(user) - delete "/v3/apps/#{app_model.guid}", nil, user_header - - expect(last_response.status).to eq(202) - expect(last_response.headers['Location']).to match(%r{/v3/jobs/#{VCAP::CloudController::PollableJobModel.last.guid}}) - - Delayed::Worker.new.work_off - - expect(app_model).not_to exist - expect(package).not_to exist - expect(droplet).not_to exist - expect(process).not_to exist - expect(deployment).not_to exist - - event = VCAP::CloudController::Event.last(2).first - expect(event.values).to include({ - type: 'audit.app.delete-request', - actee: app_model.guid, - actee_type: 'app', - actee_name: 'app_name', - actor: user.guid, - actor_type: 'user', - actor_name: '', - actor_username: user_name, - space_guid: space.guid, - organization_guid: space.organization.guid - }) - end - - context 'permissions for deleting an app' do - let(:api_call) { ->(user_headers) { delete "/v3/apps/#{app_model.guid}", nil, user_headers } } - let(:expected_codes_and_responses) do - h = Hash.new({ code: 202 }.freeze) - %w[admin_read_only global_auditor org_manager space_auditor space_manager space_supporter].each do |r| - h[r] = { code: 403, errors: CF_NOT_AUTHORIZED } - end - h['org_auditor'] = { code: 404 } - h['org_billing_manager'] = { code: 404 } - h['no_role'] = { code: 404 } - h - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - - context 'when organization is suspended' do - let(:expected_codes_and_responses) do - h = super() - h['space_developer'] = { code: 403, errors: CF_ORG_SUSPENDED } - h - end - - before do - org.update(status: VCAP::CloudController::Organization::SUSPENDED) - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - end - - context 'deleting metadata' do - before do - space.organization.add_user(user) - space.add_developer(user) - end - - it_behaves_like 'resource with metadata' do - let(:resource) { app_model } - let(:api_call) do - -> { delete "/v3/apps/#{resource.guid}", nil, user_header } - end - end - end - end - - describe 'PATCH /v3/apps/:guid' do - let(:app_model) do - VCAP::CloudController::AppModel.make( - :buildpack, - name: 'original_name', - space: space, - environment_variables: { 'ORIGINAL' => 'ENVAR' }, - desired_state: 'STOPPED' - ) - end - let!(:web_process) { VCAP::CloudController::ProcessModel.make(app: app_model, state: VCAP::CloudController::ProcessModel::STOPPED) } - let(:stack) { VCAP::CloudController::Stack.make(name: 'redhat') } - - let(:update_request) do - { - name: 'new-name', - lifecycle: { - type: 'buildpack', - data: { - buildpacks: ['http://gitwheel.org/my-app'], - stack: stack.name - } - }, - metadata: { - labels: { - 'release' => 'stable', - 'code.cloudfoundry.org/cloud_controller_ng' => 'awesome', - 'delete-me' => nil - }, - annotations: { - 'contacts' => 'Bill tel(1111111) email(bill@fixme), Bob tel(222222) pager(3333333#555) email(bob@fixme)', - 'anno1' => 'new-value', - 'please' => nil - } - } - } - end - - let(:expected_response_object) do - { - 'name' => 'new-name', - 'guid' => app_model.guid, - 'state' => 'STOPPED', - 'lifecycle' => { - 'type' => 'buildpack', - 'data' => { - 'buildpacks' => ['http://gitwheel.org/my-app'], - 'stack' => stack.name - } - }, - 'relationships' => { - 'space' => { - 'data' => { - 'guid' => space.guid - } - }, - 'current_droplet' => { - 'data' => { - 'guid' => nil - } - } - }, - 'created_at' => iso8601, - 'updated_at' => iso8601, - 'metadata' => { - 'labels' => { - 'release' => 'stable', - 'code.cloudfoundry.org/cloud_controller_ng' => 'awesome' - }, - 'annotations' => { - 'contacts' => 'Bill tel(1111111) email(bill@fixme), Bob tel(222222) pager(3333333#555) email(bob@fixme)', - 'anno1' => 'new-value' - } - }, - 'links' => { - 'self' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}" }, - 'processes' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/processes" }, - 'packages' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/packages" }, - 'environment_variables' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/environment_variables" }, - 'space' => { 'href' => "#{link_prefix}/v3/spaces/#{space.guid}" }, - 'current_droplet' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/droplets/current" }, - 'droplets' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/droplets" }, - 'tasks' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/tasks" }, - 'start' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/start", 'method' => 'POST' }, - 'stop' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/stop", 'method' => 'POST' }, - 'clear_buildpack_cache' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/clear_buildpack_cache", 'method' => 'POST' }, - 'revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/revisions" }, - 'deployed_revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/revisions/deployed" }, - 'features' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/features" } - } - } - end - - before do - VCAP::CloudController::AppLabelModel.make( - resource_guid: app_model.guid, - key_name: 'delete-me', - value: 'yes' - ) - - VCAP::CloudController::AppAnnotationModel.make( - resource_guid: app_model.guid, - key_name: 'anno1', - value: 'original-value' - ) - - VCAP::CloudController::AppAnnotationModel.make( - resource_guid: app_model.guid, - key_name: 'please', - value: 'delete this' - ) - end - - it 'updates an app' do - space.organization.add_user(user) - space.add_developer(user) - expect_any_instance_of(VCAP::CloudController::Diego::Runner).not_to receive(:update_metric_tags) - patch "/v3/apps/#{app_model.guid}", update_request.to_json, user_header - expect(last_response.status).to eq(200) - - app_model.reload - - parsed_response = Oj.load(last_response.body) - expect(parsed_response).to be_a_response_like(expected_response_object) - - event = VCAP::CloudController::Event.last - expect(event.values).to include({ - type: 'audit.app.update', - actee: app_model.guid, - actee_type: 'app', - actee_name: 'new-name', - actor: user.guid, - actor_type: 'user', - actor_name: user_email, - actor_username: user_name, - space_guid: space.guid, - organization_guid: space.organization.guid - }) - metadata_request = { - 'name' => 'new-name', - 'lifecycle' => { - 'type' => 'buildpack', - 'data' => { - 'buildpacks' => ['http://gitwheel.org/my-app'], - 'stack' => stack.name - } - }, - 'metadata' => { - 'labels' => { - 'release' => 'stable', - 'code.cloudfoundry.org/cloud_controller_ng' => 'awesome', - 'delete-me' => nil - }, - 'annotations' => { - 'contacts' => 'Bill tel(1111111) email(bill@fixme), Bob tel(222222) pager(3333333#555) email(bob@fixme)', - 'anno1' => 'new-value', - 'please' => nil - } - } - } - expect(event.metadata['request']).to eq(metadata_request) - end - - context 'when the app has a process that is started' do - let!(:web_process) { VCAP::CloudController::ProcessModel.make(app: app_model, state: VCAP::CloudController::ProcessModel::STARTED) } - - before do - app_model.desired_state = VCAP::CloudController::ProcessModel::STARTED - end - - it 'notifies diego that an app has been renamed' do - space.organization.add_user(user) - space.add_developer(user) - expect_any_instance_of(VCAP::CloudController::Diego::Runner).to receive(:update_metric_tags) - patch "/v3/apps/#{app_model.guid}", update_request.to_json, user_header - expect(last_response.status).to eq(200) - end - end - - context 'permissions for updating an app' do - let(:api_call) { ->(user_headers) { patch "/v3/apps/#{app_model.guid}", update_request.to_json, user_headers } } - let(:expected_codes_and_responses) do - h = Hash.new({ code: 200, response_object: expected_response_object }.freeze) - %w[admin_read_only global_auditor org_manager space_auditor space_manager space_supporter].each do |r| - h[r] = { code: 403, errors: CF_NOT_AUTHORIZED } - end - h['org_auditor'] = { code: 404 } - h['org_billing_manager'] = { code: 404 } - h['no_role'] = { code: 404 } - h - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - - context 'when organization is suspended' do - let(:expected_codes_and_responses) do - h = super() - h['space_developer'] = { code: 403, errors: CF_ORG_SUSPENDED } - h - end - - before do - org.update(status: VCAP::CloudController::Organization::SUSPENDED) - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - end - - context 'telemetry' do - before do - space.organization.add_user(user) - space.add_developer(user) - end - - it 'logs the required fields when the app gets updated' do - Timecop.freeze do - expected_json = { - 'telemetry-source' => 'cloud_controller_ng', - 'telemetry-time' => Time.now.to_datetime.rfc3339, - 'update-app' => { - 'api-version' => 'v3', - 'app-id' => OpenSSL::Digest::SHA256.hexdigest(app_model.guid), - 'user-id' => OpenSSL::Digest::SHA256.hexdigest(user.guid) - } - } - expect_any_instance_of(ActiveSupport::Logger).to receive(:info).with(Oj.dump(expected_json)) - - patch "/v3/apps/#{app_model.guid}", update_request.to_json, user_header - expect(last_response.status).to eq(200), last_response.body - end - end - end - end - - describe 'POST /v3/apps/:guid/actions/start' do - let(:stack) { VCAP::CloudController::Stack.make(name: 'stack-name') } - let(:app_model) do - VCAP::CloudController::AppModel.make( - :buildpack, - name: 'app-name', - space: space, - desired_state: 'STOPPED' - ) - end - - context 'app lifecycle is buildpack' do - let!(:droplet) do - VCAP::CloudController::DropletModel.make( - :buildpack, - app: app_model, - state: VCAP::CloudController::DropletModel::STAGED_STATE - ) - end - - before do - app_model.lifecycle_data.buildpacks = ['http://example.com/git'] - app_model.lifecycle_data.stack = stack.name - app_model.lifecycle_data.save - app_model.droplet = droplet - app_model.save - end - - context 'starting an app' do - let(:api_call) { ->(user_headers) { post "/v3/apps/#{app_model.guid}/actions/start", nil, user_headers } } - let(:app_start_response_object) do - { - 'name' => 'app-name', - 'guid' => app_model.guid, - 'state' => 'STARTED', - 'created_at' => iso8601, - 'updated_at' => iso8601, - 'metadata' => { 'labels' => {}, 'annotations' => {} }, - 'lifecycle' => { - 'type' => 'buildpack', - 'data' => { - 'buildpacks' => ['http://example.com/git'], - 'stack' => 'stack-name' - } - }, - 'relationships' => { - 'space' => { - 'data' => { - 'guid' => space.guid - } - }, - 'current_droplet' => { - 'data' => { - 'guid' => droplet.guid - } - } - }, - 'links' => { - 'self' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}" }, - 'processes' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/processes" }, - 'packages' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/packages" }, - 'environment_variables' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/environment_variables" }, - 'space' => { 'href' => "#{link_prefix}/v3/spaces/#{space.guid}" }, - 'current_droplet' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/droplets/current" }, - 'droplets' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/droplets" }, - 'tasks' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/tasks" }, - 'start' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/start", 'method' => 'POST' }, - 'stop' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/stop", 'method' => 'POST' }, - 'clear_buildpack_cache' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/clear_buildpack_cache", 'method' => 'POST' }, - 'revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/revisions" }, - 'deployed_revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/revisions/deployed" }, - 'features' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/features" } - } - } - end - - let(:expected_codes_and_responses) do - h = Hash.new({ code: 403, errors: CF_NOT_AUTHORIZED }.freeze) - h['no_role'] = { code: 404 } - h['org_auditor'] = { code: 404 } - h['org_billing_manager'] = { code: 404 } - h['admin'] = { - code: 200, - response_object: app_start_response_object - } - h['space_supporter'] = { - code: 200, - response_object: app_start_response_object - } - h['space_developer'] = { - code: 200, - response_object: app_start_response_object - } - h - end - - before do - space.organization.add_user(user) - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - - context 'when organization is suspended' do - let(:expected_codes_and_responses) do - h = super() - %w[space_supporter space_developer].each { |r| h[r] = { code: 403, errors: CF_ORG_SUSPENDED } } - h - end - - before do - org.update(status: VCAP::CloudController::Organization::SUSPENDED) - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - - describe 'limiting the application log rates' do - let(:log_rate_limit) { -1 } - let(:space_log_rate_limit) { -1 } - let(:org_log_rate_limit) { -1 } - let(:org_quota_definition) { VCAP::CloudController::QuotaDefinition.make(log_rate_limit: org_log_rate_limit) } - let(:org) { VCAP::CloudController::Organization.make(quota_definition: org_quota_definition) } - let(:space_quota_definition) { VCAP::CloudController::SpaceQuotaDefinition.make(organization: org, log_rate_limit: space_log_rate_limit) } - let(:space) { VCAP::CloudController::Space.make(organization: org, space_quota_definition: space_quota_definition) } - let!(:process_model) { VCAP::CloudController::ProcessModel.make(app: app_model, log_rate_limit: log_rate_limit) } - let(:app_model) do - VCAP::CloudController::AppModel.make( - :buildpack, - name: 'app-name', - space: space, - desired_state: 'STOPPED' - ) - end - let(:droplet) { VCAP::CloudController::DropletModel.make(app: app_model, process_types: { web: 'webby' }) } - - before do - app_model.update(droplet_guid: droplet.guid) - end - - describe 'space quotas' do - context 'when both the space and the app do not specify a log rate limit' do - let(:log_rate_limit) { -1 } - let(:space_log_rate_limit) { -1 } - - it 'starts the app successfully' do - post "/v3/apps/#{app_model.guid}/actions/start", nil, admin_header - - expect(last_response.status).to eq(200) - end - end - - context "when the app fits in the space's log rate limit" do - let(:log_rate_limit) { 199 } - let(:space_log_rate_limit) { 200 } - - it 'starts the app successfully' do - post "/v3/apps/#{app_model.guid}/actions/start", nil, admin_header - - expect(last_response.status).to eq(200) - end - end - - context "when the app's log rate limit is unspecified, but the space specifies a log rate limit" do - let(:log_rate_limit) { -1 } - let(:space_log_rate_limit) { 200 } - - it 'fails to start the app' do - post "/v3/apps/#{app_model.guid}/actions/start", nil, admin_header - - expect(last_response.status).to eq(422) - expect(last_response).to have_error_message("log_rate_limit cannot be unlimited in space '#{space.name}'.") - end - end - - context "when the app's log rate limit is larger than the limit specified by the space" do - let(:log_rate_limit) { 201 } - let(:space_log_rate_limit) { 200 } - - it 'fails to start the app' do - post "/v3/apps/#{app_model.guid}/actions/start", nil, admin_header - - expect(last_response.status).to eq(422) - expect(last_response).to have_error_message('log_rate_limit exceeds space log rate quota') - end - end - - context "when the space's quota is more strict that the org's quota, the space quota controls" do - let(:log_rate_limit) { 201 } - let(:space_log_rate_limit) { 200 } - let(:org_log_rate_limit) { 201 } - - it 'fails to start the app' do - post "/v3/apps/#{app_model.guid}/actions/start", nil, admin_header - - expect(last_response.status).to eq(422) - expect(last_response).to have_error_message('log_rate_limit exceeds space log rate quota') - end - end - end - - describe 'organization quotas' do - context 'when both the org and the app do not specify a log rate limit' do - let(:log_rate_limit) { -1 } - let(:org_log_rate_limit) { -1 } - - it 'starts the app successfully' do - post "/v3/apps/#{app_model.guid}/actions/start", nil, admin_header - - expect(last_response.status).to eq(200) - end - end - - context "when the app fits in the org's log rate limit" do - let(:log_rate_limit) { 199 } - let(:org_log_rate_limit) { 200 } - - it 'starts the app successfully' do - post "/v3/apps/#{app_model.guid}/actions/start", nil, admin_header - - expect(last_response.status).to eq(200) - end - end - - context "when the app's log rate limit is unspecified, but the org specifies a log rate limit" do - let(:log_rate_limit) { -1 } - let(:org_log_rate_limit) { 200 } - - it 'fails to start the app' do - post "/v3/apps/#{app_model.guid}/actions/start", nil, admin_header - - expect(last_response.status).to eq(422) - expect(last_response).to have_error_message("log_rate_limit cannot be unlimited in organization '#{org.name}'.") - end - end - - context "when the app's log rate limit is larger than the limit specified by the org" do - let(:log_rate_limit) { 201 } - let(:org_log_rate_limit) { 200 } - - it 'fails to start the app' do - post "/v3/apps/#{app_model.guid}/actions/start", nil, admin_header - - expect(last_response.status).to eq(422) - expect(last_response).to have_error_message('log_rate_limit exceeds organization log rate quota') - end - end - - context "when the org's quota is more strict that the space's quota, the org quota controls" do - let(:log_rate_limit) { 201 } - let(:space_log_rate_limit) { 202 } - let(:org_log_rate_limit) { 200 } - - it 'fails to start the app' do - post "/v3/apps/#{app_model.guid}/actions/start", nil, admin_header - - expect(last_response.status).to eq(422) - expect(last_response).to have_error_message('log_rate_limit exceeds organization log rate quota') - end - end - end - end - end - - context 'events' do - before do - space.organization.add_user(user) - space.add_developer(user) - end - - it 'issues the required events when the app starts' do - post "/v3/apps/#{app_model.guid}/actions/start", nil, user_header - - event = VCAP::CloudController::Event.last - expect(event.values).to include({ - type: 'audit.app.start', - actee: app_model.guid, - actee_type: 'app', - actee_name: 'app-name', - actor: user.guid, - actor_type: 'user', - actor_name: user_email, - actor_username: user_name, - space_guid: space.guid, - organization_guid: space.organization.guid - }) - end - end - - context 'telemetry' do - before do - space.organization.add_user(user) - space.add_developer(user) - end - - it 'logs the required fields when the app starts' do - Timecop.freeze do - expected_json = { - 'telemetry-source' => 'cloud_controller_ng', - 'telemetry-time' => Time.now.to_datetime.rfc3339, - 'start-app' => { - 'api-version' => 'v3', - 'app-id' => OpenSSL::Digest::SHA256.hexdigest(app_model.guid), - 'user-id' => OpenSSL::Digest::SHA256.hexdigest(user.guid) - } - } - expect_any_instance_of(ActiveSupport::Logger).to receive(:info).with(Oj.dump(expected_json)) - post "/v3/apps/#{app_model.guid}/actions/start", nil, user_header - - expect(last_response.status).to eq(200), last_response.body - end - end - end - end - - describe 'when there is a new desired droplet and revision feature is turned on' do - let(:droplet) do - VCAP::CloudController::DropletModel.make( - app: app_model, - process_types: { web: 'rackup' }, - state: VCAP::CloudController::DropletModel::STAGED_STATE, - package: VCAP::CloudController::PackageModel.make - ) - end - - before do - space.organization.add_user(user) - space.add_developer(user) - app_model.update(revisions_enabled: true) - end - - it 'creates a new revision' do - expect do - patch "/v3/apps/#{app_model.guid}/relationships/current_droplet", { data: { guid: droplet.guid } }.to_json, user_header - expect(last_response.status).to eq(200) - end.not_to(change(VCAP::CloudController::RevisionModel, :count)) - - expect do - post "/v3/apps/#{app_model.guid}/actions/start", nil, user_header - expect(last_response.status).to eq(200), last_response.body - end.to change(VCAP::CloudController::RevisionModel, :count).by(1) - end - end - end - - describe 'POST /v3/apps/:guid/actions/stop' do - let(:stack) { VCAP::CloudController::Stack.make(name: 'stack-name') } - let(:app_model) do - VCAP::CloudController::AppModel.make( - :buildpack, - name: 'app-name', - space: space, - desired_state: 'STARTED' - ) - end - let!(:droplet) do - VCAP::CloudController::DropletModel.make(:buildpack, - app: app_model, - state: VCAP::CloudController::DropletModel::STAGED_STATE) - end - - before do - app_model.lifecycle_data.buildpacks = ['http://example.com/git'] - app_model.lifecycle_data.stack = stack.name - app_model.lifecycle_data.save - app_model.droplet = droplet - app_model.save - end - - context 'stopping an app' do - let(:api_call) { ->(user_headers) { post "/v3/apps/#{app_model.guid}/actions/stop", nil, user_headers } } - let(:app_stop_response_object) do - { - 'name' => 'app-name', - 'guid' => app_model.guid, - 'state' => 'STOPPED', - 'created_at' => iso8601, - 'updated_at' => iso8601, - 'metadata' => { 'labels' => {}, 'annotations' => {} }, - 'lifecycle' => { - 'type' => 'buildpack', - 'data' => { - 'buildpacks' => ['http://example.com/git'], - 'stack' => 'stack-name' - } - }, - 'relationships' => { - 'space' => { - 'data' => { - 'guid' => space.guid - } - }, - 'current_droplet' => { - 'data' => { - 'guid' => droplet.guid - } - } - }, - 'links' => { - 'self' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}" }, - 'processes' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/processes" }, - 'packages' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/packages" }, - 'environment_variables' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/environment_variables" }, - 'space' => { 'href' => "#{link_prefix}/v3/spaces/#{space.guid}" }, - 'current_droplet' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/droplets/current" }, - 'droplets' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/droplets" }, - 'tasks' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/tasks" }, - 'start' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/start", 'method' => 'POST' }, - 'stop' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/stop", 'method' => 'POST' }, - 'clear_buildpack_cache' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/clear_buildpack_cache", 'method' => 'POST' }, - 'revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/revisions" }, - 'deployed_revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/revisions/deployed" }, - 'features' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/features" } - } - } - end - - let(:expected_codes_and_responses) do - h = Hash.new({ code: 403, errors: CF_NOT_AUTHORIZED }.freeze) - h['no_role'] = { code: 404 } - h['org_auditor'] = { code: 404 } - h['org_billing_manager'] = { code: 404 } - h['admin'] = { - code: 200, - response_object: app_stop_response_object - } - h['space_supporter'] = { - code: 200, - response_object: app_stop_response_object - } - h['space_developer'] = { - code: 200, - response_object: app_stop_response_object - } - h - end - - before do - space.organization.add_user(user) - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - - context 'when organization is suspended' do - let(:expected_codes_and_responses) do - h = super() - %w[space_supporter space_developer].each { |r| h[r] = { code: 403, errors: CF_ORG_SUSPENDED } } - h - end - - before do - org.update(status: VCAP::CloudController::Organization::SUSPENDED) - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - end - - context 'events' do - before do - space.organization.add_user(user) - space.add_developer(user) - end - - it 'issues the required events when the app stops' do - post "/v3/apps/#{app_model.guid}/actions/stop", nil, user_header - - event = VCAP::CloudController::Event.last - expect(event.values).to include({ - type: 'audit.app.stop', - actee: app_model.guid, - actee_type: 'app', - actee_name: 'app-name', - actor: user.guid, - actor_type: 'user', - actor_name: user_email, - actor_username: user_name, - space_guid: space.guid, - organization_guid: space.organization.guid - }) - end - end - - context 'telemetry' do - before do - space.organization.add_user(user) - space.add_developer(user) - end - - it 'logs the required fields when the app stops' do - Timecop.freeze do - expected_json = { - 'telemetry-source' => 'cloud_controller_ng', - 'telemetry-time' => Time.now.to_datetime.rfc3339, - 'stop-app' => { - 'api-version' => 'v3', - 'app-id' => OpenSSL::Digest::SHA256.hexdigest(app_model.guid), - 'user-id' => OpenSSL::Digest::SHA256.hexdigest(user.guid) - } - } - expect_any_instance_of(ActiveSupport::Logger).to receive(:info).with(Oj.dump(expected_json)) - - post "/v3/apps/#{app_model.guid}/actions/stop", nil, user_header - - expect(last_response.status).to eq(200), last_response.body - end - end - end - end - - describe 'POST /v3/apps/:guid/actions/restart' do - let(:stack) { VCAP::CloudController::Stack.make(name: 'stack-name') } - let(:app_model) do - VCAP::CloudController::AppModel.make( - :buildpack, - name: 'app-name', - space: space, - desired_state: 'STARTED' - ) - end - - context 'app lifecycle is buildpack' do - let!(:droplet) do - VCAP::CloudController::DropletModel.make( - :buildpack, - app: app_model, - state: VCAP::CloudController::DropletModel::STAGED_STATE - ) - end - - before do - app_model.lifecycle_data.buildpacks = ['http://example.com/git'] - app_model.lifecycle_data.stack = stack.name - app_model.lifecycle_data.save - app_model.droplet = droplet - app_model.save - end - - context 'restarting an app' do - let(:api_call) { ->(user_headers) { post "/v3/apps/#{app_model.guid}/actions/restart", nil, user_headers } } - let(:app_restart_response_object) do - { - 'name' => 'app-name', - 'guid' => app_model.guid, - 'state' => 'STARTED', - 'created_at' => iso8601, - 'updated_at' => iso8601, - 'metadata' => { 'labels' => {}, 'annotations' => {} }, - 'lifecycle' => { - 'type' => 'buildpack', - 'data' => { - 'buildpacks' => ['http://example.com/git'], - 'stack' => 'stack-name' - } - }, - 'relationships' => { - 'space' => { - 'data' => { - 'guid' => space.guid - } - }, - 'current_droplet' => { - 'data' => { - 'guid' => droplet.guid - } - } - }, - 'links' => { - 'self' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}" }, - 'processes' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/processes" }, - 'packages' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/packages" }, - 'environment_variables' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/environment_variables" }, - 'space' => { 'href' => "#{link_prefix}/v3/spaces/#{space.guid}" }, - 'current_droplet' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/droplets/current" }, - 'droplets' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/droplets" }, - 'tasks' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/tasks" }, - 'start' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/start", 'method' => 'POST' }, - 'stop' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/stop", 'method' => 'POST' }, - 'clear_buildpack_cache' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/clear_buildpack_cache", 'method' => 'POST' }, - 'revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/revisions" }, - 'deployed_revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/revisions/deployed" }, - 'features' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/features" } - } - } - end - - let(:expected_codes_and_responses) do - h = Hash.new({ code: 403, errors: CF_NOT_AUTHORIZED }.freeze) - h['no_role'] = { code: 404 } - h['org_auditor'] = { code: 404 } - h['org_billing_manager'] = { code: 404 } - h['admin'] = { - code: 200, - response_object: app_restart_response_object - } - h['space_supporter'] = { - code: 200, - response_object: app_restart_response_object - } - h['space_developer'] = { - code: 200, - response_object: app_restart_response_object - } - h - end - - before do - space.organization.add_user(user) - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - - context 'when organization is suspended' do - let(:expected_codes_and_responses) do - h = super() - %w[space_supporter space_developer].each { |r| h[r] = { code: 403, errors: CF_ORG_SUSPENDED } } - h - end - - before do - org.update(status: VCAP::CloudController::Organization::SUSPENDED) - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - end - - context 'telemetry' do - before do - space.organization.add_user(user) - space.add_developer(user) - end - - it 'logs the required fields when the app is restarted' do - Timecop.freeze do - expected_json = { - 'telemetry-source' => 'cloud_controller_ng', - 'telemetry-time' => Time.now.to_datetime.rfc3339, - 'restart-app' => { - 'api-version' => 'v3', - 'app-id' => OpenSSL::Digest::SHA256.hexdigest(app_model.guid), - 'user-id' => OpenSSL::Digest::SHA256.hexdigest(user.guid) - } - } - expect_any_instance_of(ActiveSupport::Logger).to receive(:info).with(Oj.dump(expected_json)) - - post "/v3/apps/#{app_model.guid}/actions/restart", nil, user_header - - expect(last_response.status).to eq(200), last_response.body - end - end - end - end - end - - describe 'GET /v3/apps/:guid/relationships/current_droplet' do - let(:api_call) { ->(user_headers) { get "/v3/apps/#{droplet_model.app_guid}/relationships/current_droplet", nil, user_headers } } - let(:app_model) { VCAP::CloudController::AppModel.make(space_guid: space.guid) } - let!(:droplet_model) { VCAP::CloudController::DropletModel.make(app_guid: app_model.guid) } - let(:expected_response) do - { - 'data' => { - 'guid' => droplet_model.guid - }, - 'links' => { - 'self' => { 'href' => "#{link_prefix}/v3/apps/#{droplet_model.app_guid}/relationships/current_droplet" }, - 'related' => { 'href' => "#{link_prefix}/v3/apps/#{droplet_model.app_guid}/droplets/current" } - } - } - end - - let(:expected_codes_and_responses) do - h = Hash.new({ code: 200, response_object: expected_response }.freeze) - h['no_role'] = { code: 404 } - h['org_billing_manager'] = { code: 404 } - h['org_auditor'] = { code: 404 } - h - end - - before do - app_model.droplet_guid = droplet_model.guid - app_model.save - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - - describe 'GET /v3/apps/:guid/droplets/current' do - let(:api_call) { ->(user_headers) { get "/v3/apps/#{droplet_model.app_guid}/droplets/current", nil, user_headers } } - let(:app_model) { VCAP::CloudController::AppModel.make(space_guid: space.guid) } - let(:package_model) { VCAP::CloudController::PackageModel.make(app_guid: app_model.guid) } - let!(:droplet_model) do - VCAP::CloudController::DropletModel.make( - app_guid: app_model.guid, - package_guid: package_model.guid, - buildpack_receipt_buildpack: 'http://buildpack.git.url.com', - error_description: 'example error', - execution_metadata: 'some-data', - droplet_hash: 'shalalala', - sha256_checksum: 'droplet-sha256-checksum', - process_types: { 'web' => 'start-command' } - ) - end - let(:expected_response) do - { - 'guid' => droplet_model.guid, - 'state' => VCAP::CloudController::DropletModel::STAGED_STATE, - 'error' => 'example error', - 'lifecycle' => { - 'type' => 'buildpack', - 'data' => {} - }, - 'checksum' => { 'type' => 'sha256', 'value' => 'droplet-sha256-checksum' }, - 'buildpacks' => [{ 'name' => 'http://buildpack.git.url.com', 'detect_output' => nil, 'buildpack_name' => nil, 'version' => nil }], - 'stack' => 'stack-name', - 'execution_metadata' => 'some-data', - 'process_types' => { 'web' => 'start-command' }, - 'image' => nil, - 'created_at' => iso8601, - 'updated_at' => iso8601, - 'relationships' => { 'app' => { 'data' => { 'guid' => app_model.guid } } }, - 'links' => { - 'self' => { 'href' => "#{link_prefix}/v3/droplets/#{droplet_model.guid}" }, - 'package' => { 'href' => "#{link_prefix}/v3/packages/#{package_model.guid}" }, - 'app' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}" }, - 'download' => { 'href' => "#{link_prefix}/v3/droplets/#{droplet_model.guid}/download" }, - 'assign_current_droplet' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/relationships/current_droplet", 'method' => 'PATCH' } - }, - 'metadata' => { - 'labels' => {}, - 'annotations' => {} - } - } - end - let(:expected_codes_and_responses) do - h = Hash.new({ code: 200, response_object: expected_response }.freeze) - h['no_role'] = { code: 404 } - h['org_billing_manager'] = { code: 404 } - h['org_auditor'] = { code: 404 } - h - end - - before do - droplet_model.buildpack_lifecycle_data.update(buildpacks: ['http://buildpack.git.url.com'], stack: 'stack-name') - app_model.droplet_guid = droplet_model.guid - app_model.save - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - - describe 'PATCH /v3/apps/:guid/relationships/current_droplet' do - let(:stack) { VCAP::CloudController::Stack.make(name: 'stack-name') } - let(:app_model) do - VCAP::CloudController::AppModel.make( - :buildpack, - name: 'my_app', - space: space, - desired_state: 'STOPPED' - ) - end - let(:droplet) do - VCAP::CloudController::DropletModel.make( - :docker, - app: app_model, - process_types: { web: 'rackup' }, - state: VCAP::CloudController::DropletModel::STAGED_STATE, - package: VCAP::CloudController::PackageModel.make - ) - end - let(:request_body) { { data: { guid: droplet.guid } } } - - before do - app_model.lifecycle_data.buildpacks = ['http://example.com/git'] - app_model.lifecycle_data.stack = stack.name - app_model.lifecycle_data.save - end - - context 'assigning the current droplet of the app' do - let(:api_call) { ->(user_headers) { patch "/v3/apps/#{app_model.guid}/relationships/current_droplet", request_body.to_json, user_headers } } - let(:current_droplet_response_object) do - { - 'data' => { - 'guid' => droplet.guid - }, - 'links' => { - 'self' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/relationships/current_droplet" }, - 'related' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/droplets/current" } - } - } - end - - let(:expected_codes_and_responses) do - h = Hash.new({ code: 403, errors: CF_NOT_AUTHORIZED }.freeze) - h['no_role'] = { code: 404 } - h['org_auditor'] = { code: 404 } - h['org_billing_manager'] = { code: 404 } - h['admin'] = { - code: 200, - response_object: current_droplet_response_object - } - h['space_supporter'] = { - code: 200, - response_object: current_droplet_response_object - } - h['space_developer'] = { - code: 200, - response_object: current_droplet_response_object - } - h - end - - before do - space.organization.add_user(user) - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - - context 'when organization is suspended' do - let(:expected_codes_and_responses) do - h = super() - %w[space_supporter space_developer].each { |r| h[r] = { code: 403, errors: CF_ORG_SUSPENDED } } - h - end - - before do - org.update(status: VCAP::CloudController::Organization::SUSPENDED) - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - end - - context 'events' do - before do - space.organization.add_user(user) - space.add_developer(user) - end - - it 'creates audit.app.droplet.mapped event' do - patch "/v3/apps/#{app_model.guid}/relationships/current_droplet", request_body.to_json, user_header - - events = VCAP::CloudController::Event.where(actor: user.guid).all - - droplet_event = events.find { |e| e.type == 'audit.app.droplet.mapped' } - expect(droplet_event.values).to include({ - type: 'audit.app.droplet.mapped', - actee: app_model.guid, - actee_type: 'app', - actee_name: 'my_app', - actor: user.guid, - actor_type: 'user', - actor_name: user_email, - actor_username: user_name, - space_guid: space.guid, - organization_guid: space.organization.guid - }) - expect(droplet_event.metadata).to eq({ 'request' => { 'droplet_guid' => droplet.guid } }) - - expect(app_model.reload.processes.count).to eq(1) - end - - context 'with two process types' do - let(:droplet) do - VCAP::CloudController::DropletModel.make( - app: app_model, - process_types: { web: 'rackup', other: 'cron' }, - state: VCAP::CloudController::DropletModel::STAGED_STATE - ) - end - - it 'creates audit.app.process.create events for each process' do - patch "/v3/apps/#{app_model.guid}/relationships/current_droplet", request_body.to_json, user_header - - expect(last_response.status).to eq(200) - - events = VCAP::CloudController::Event.where(actor: user.guid).all - - expect(app_model.reload.processes.count).to eq(2) - web_process = app_model.processes.find { |i| i.type == 'web' } - other_process = app_model.processes.find { |i| i.type == 'other' } - expect(web_process).to be_present - expect(other_process).to be_present - - web_process_event = events.find { |e| e.metadata['process_guid'] == web_process.guid } - expect(web_process_event.values).to include({ - type: 'audit.app.process.create', - actee: app_model.guid, - actee_type: 'app', - actee_name: 'my_app', - actor: user.guid, - actor_type: 'user', - actor_name: user_email, - actor_username: user_name, - space_guid: space.guid, - organization_guid: space.organization.guid - }) - expect(web_process_event.metadata).to eq({ 'process_guid' => web_process.guid, 'process_type' => 'web' }) - - other_process_event = events.find { |e| e.metadata['process_guid'] == other_process.guid } - expect(other_process_event.values).to include({ - type: 'audit.app.process.create', - actee: app_model.guid, - actee_type: 'app', - actee_name: 'my_app', - actor: user.guid, - actor_type: 'user', - actor_name: user_email, - actor_username: user_name, - space_guid: space.guid, - organization_guid: space.organization.guid - }) - expect(other_process_event.metadata).to eq({ 'process_guid' => other_process.guid, 'process_type' => 'other' }) - end - end - end - - context 'sidecars' do - let(:droplet) do - VCAP::CloudController::DropletModel.make( - :docker, - app: app_model, - process_types: { web: 'rackup' }, - state: VCAP::CloudController::DropletModel::STAGED_STATE, - package: VCAP::CloudController::PackageModel.make, - sidecars: - [ - { - name: 'sidecar_one', - command: 'bundle exec rackup', - process_types: ['web'], - memory: 300 - } - ] - ) - end - - before do - space.organization.add_user(user) - space.add_developer(user) - end - - it 'creates sidecars that were saved on the droplet' do - patch "/v3/apps/#{app_model.guid}/relationships/current_droplet", request_body.to_json, user_header - - expect(last_response.status).to eq(200) - - expect(app_model.reload.processes.count).to eq(1) - expect(app_model.reload.sidecars.count).to eq(1) - end - - it 'logs the create-sidecar event' do - Timecop.freeze do - expected_json = { - 'telemetry-source' => 'cloud_controller_ng', - 'telemetry-time' => Time.now.to_datetime.rfc3339, - 'create-sidecar' => { - 'api-version' => 'v3', - 'origin' => 'buildpack', - 'memory-in-mb' => 300, - 'process-types' => ['web'], - 'app-id' => OpenSSL::Digest::SHA256.hexdigest(app_model.guid) - } - } - expect_any_instance_of(ActiveSupport::Logger).to receive(:info).with(Oj.dump(expected_json)) - - patch "/v3/apps/#{app_model.guid}/relationships/current_droplet", request_body.to_json, user_header - - expect(last_response.status).to eq(200), last_response.body - end - end - end - end - - describe 'PATCH /v3/apps/:guid/environment_variables' do - before do - space.organization.add_user(user) - end - - let(:update_request) do - { - var: { - override: 'new-value', - new_key: 'brand-new-value' - } - } - end - let(:app_model) do - VCAP::CloudController::AppModel.make( - name: 'name1', - space: space, - desired_state: 'STOPPED', - environment_variables: { - override: 'original', - preserve: 'keep' - } - ) - end - let(:api_call) { ->(user_headers) { patch "/v3/apps/#{app_model.guid}/environment_variables", update_request.to_json, user_headers } } - let(:app_model_response_object) do - { - 'var' => { - 'override' => 'new-value', - 'new_key' => 'brand-new-value', - 'preserve' => 'keep' - }, - 'links' => { - 'self' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/environment_variables" }, - 'app' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}" } - } - } - end - let(:expected_codes_and_responses) do - h = Hash.new({ code: 404 }.freeze) - %w[global_auditor admin_read_only org_manager space_auditor space_manager].each do |r| - h[r] = { code: 403, errors: CF_NOT_AUTHORIZED } - end - h['admin'] = h['space_developer'] = h['space_supporter'] = { - code: 200, - response_object: app_model_response_object - } - h - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - - context 'when organization is suspended' do - let(:expected_codes_and_responses) do - h = super() - %w[space_developer space_supporter].each { |r| h[r] = { code: 403, errors: CF_ORG_SUSPENDED } } - h - end - - before do - org.update(status: VCAP::CloudController::Organization::SUSPENDED) - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - end - - describe 'GET /v3/apps/:guid/environment_variables' do - let(:app_model) { VCAP::CloudController::AppModel.make(name: 'my_app', space: space, desired_state: 'STARTED', environment_variables: { meep: 'moop' }) } - let(:api_call) { ->(user_headers) { get "/v3/apps/#{app_model.guid}/environment_variables", nil, user_headers } } - let(:app_model_response_object) do - { - var: { - meep: 'moop' - }, - links: { - self: { href: "#{link_prefix}/v3/apps/#{app_model.guid}/environment_variables" }, - app: { href: "#{link_prefix}/v3/apps/#{app_model.guid}" } - } - } - end - - before do - space.organization.add_user(user) - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS do - let(:expected_codes_and_responses) do - h = Hash.new({ code: 404 }.freeze) - h['global_auditor'] = h['org_manager'] = h['space_auditor'] = h['space_manager'] = { code: 403 } - h['admin'] = h['admin_read_only'] = h['space_developer'] = h['space_supporter'] = { - code: 200, - response_object: app_model_response_object - } - h - 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_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS do - let(:expected_codes_and_responses) do - h = Hash.new({ code: 404 }.freeze) - h['global_auditor'] = h['org_manager'] = h['space_auditor'] = h['space_manager'] = h['space_developer'] = h['space_supporter'] = { code: 403 } - h['admin'] = h['admin_read_only'] = { - code: 200, - response_object: app_model_response_object - } - h - end - end - end - - context 'when the encryption_key_label is invalid' do - before do - allow_any_instance_of(ErrorPresenter).to receive(:raise_500?).and_return(false) - end - - it 'fails to decrypt the environment variables and returns a 500 error' do - app_model # ensure that app model is created before run_cipher is mocked to throw an error - allow(VCAP::CloudController::Encryptor).to receive(:run_cipher).and_raise(VCAP::CloudController::Encryptor::EncryptorError) - api_call.call(admin_headers) - - expect(last_response).to have_status_code(500) - expect(parsed_response['errors'].first['detail']).to match(/Error while processing encrypted data/i) - end - end - end - - describe 'GET /v3/apps/:guid/permissions' do - let(:org) { VCAP::CloudController::Organization.make } - let(:space) { VCAP::CloudController::Space.make(organization: org) } - let(:app_model) { VCAP::CloudController::AppModel.make(name: 'name1', space: space, desired_state: 'STOPPED') } - let(:api_call) { ->(user_headers) { get "/v3/apps/#{app_model.guid}/permissions", nil, user_headers } } - - let(:read_all_response) do - { - read_basic_data: true, - read_sensitive_data: true - } - end - - let(:read_basic_response) do - { - read_basic_data: true, - read_sensitive_data: false - } - end - - let(:expected_codes_and_responses) do - h = Hash.new({ code: 404 }.freeze) - h['admin'] = { code: 200, response_object: read_all_response } - h['admin_read_only'] = { code: 200, response_object: read_all_response } - h['global_auditor'] = { code: 200, response_object: read_basic_response } - h['org_manager'] = { code: 200, response_object: read_basic_response } - h['space_manager'] = { code: 200, response_object: read_basic_response } - h['space_auditor'] = { code: 200, response_object: read_basic_response } - h['space_developer'] = { code: 200, response_object: read_all_response } - h['space_supporter'] = { code: 200, response_object: read_basic_response } - h - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end -end diff --git a/spec/request/routes/apps_routes_spec.rb b/spec/request/routes/apps_routes_spec.rb new file mode 100644 index 00000000000..8357d590370 --- /dev/null +++ b/spec/request/routes/apps_routes_spec.rb @@ -0,0 +1,173 @@ +require 'spec_helper' +require 'request_spec_shared_examples' +require_relative 'shared_context' + +# Split from spec/request/routes_spec.rb for better test parallelization + +RSpec.describe 'Routes Request' do + include_context 'routes request spec' + + describe 'GET /v3/apps/:app_guid/routes' do + let(:app_model) { VCAP::CloudController::AppModel.make(space:) } + let(:route1) { VCAP::CloudController::Route.make(space:) } + let(:route2) { VCAP::CloudController::Route.make(space:) } + let!(:route3) { VCAP::CloudController::Route.make(space:) } + let!(:route_mapping1) { VCAP::CloudController::RouteMappingModel.make(app: app_model, route: route1, process_type: 'web') } + let!(:route_mapping2) { VCAP::CloudController::RouteMappingModel.make(app: app_model, route: route2, process_type: 'admin') } + let(:api_call) { ->(user_headers) { get "/v3/apps/#{app_model.guid}/routes", nil, user_headers } } + + let(:route1_json) do + { + guid: route1.guid, + protocol: route1.domain.protocols[0], + host: route1.host, + path: route1.path, + port: nil, + url: "#{route1.host}.#{route1.domain.name}#{route1.path}", + created_at: iso8601, + updated_at: iso8601, + destinations: contain_exactly({ + guid: route_mapping1.guid, + app: { + guid: app_model.guid, + process: { + type: route_mapping1.process_type + } + }, + weight: route_mapping1.weight, + port: route_mapping1.presented_port, + protocol: 'http1', + created_at: iso8601, + updated_at: iso8601 + }), + relationships: { + space: { + data: { guid: route1.space.guid } + }, + domain: { + data: { guid: route1.domain.guid } + } + }, + metadata: { + labels: {}, + annotations: {} + }, + links: { + self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{route1.guid}} }, + space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{route1.space.guid}} }, + destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{route1.guid}/destinations} }, + domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{route1.domain.guid}} } + }, + options: {} + } + end + + let(:route2_json) do + { + guid: route2.guid, + protocol: route2.domain.protocols[0], + host: route2.host, + path: route2.path, + port: nil, + url: "#{route2.host}.#{route2.domain.name}#{route2.path}", + created_at: iso8601, + updated_at: iso8601, + destinations: contain_exactly({ + guid: route_mapping2.guid, + app: { + guid: app_model.guid, + process: { + type: route_mapping2.process_type + } + }, + weight: route_mapping2.weight, + port: route_mapping2.presented_port, + protocol: 'http1', + created_at: iso8601, + updated_at: iso8601 + }), + relationships: { + space: { + data: { guid: route2.space.guid } + }, + domain: { + data: { guid: route2.domain.guid } + } + }, + metadata: { + labels: {}, + annotations: {} + }, + links: { + self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{route2.guid}} }, + space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{route2.space.guid}} }, + destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{route2.guid}/destinations} }, + domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{route2.domain.guid}} } + }, + options: {} + } + end + + context 'when the user is a member in the app space' do + let(:expected_codes_and_responses) do + h = Hash.new( + { code: 200, + response_objects: [route1_json, route2_json] }.freeze + ) + + h['org_auditor'] = { code: 404 } + h['org_billing_manager'] = { code: 404 } + h['no_role'] = { code: 404 } + h + end + + it_behaves_like 'permissions for list endpoint', ALL_PERMISSIONS + end + + context 'ports filter' do + # Don't even think of converting the following hash to symbols ('type' => 'tcp' NOT type: 'tcp'), and you need to set the GUID + let(:router_group) { VCAP::CloudController::RoutingApi::RouterGroup.new({ 'type' => 'tcp', 'reservable_ports' => '7777,8888,9999', 'guid' => 'some-guid' }) } + let(:routing_api_client) { instance_double(VCAP::CloudController::RoutingApi::Client) } + + before do + allow_any_instance_of(CloudController::DependencyLocator).to receive(:routing_api_client).and_return(routing_api_client) + allow(routing_api_client).to receive_messages(enabled?: true, router_group: router_group) + end + + context 'when there are multiple TCP routes with different ports' do + # The following `let`s depend on the above `before do` + let(:domain_tcp) { VCAP::CloudController::SharedDomain.make(router_group_guid: router_group.guid, name: 'my.domain') } + let!(:route_with_ports_0) do + VCAP::CloudController::Route.make(host: '', space: space, domain: domain_tcp, guid: 'route-with-port-0', port: 7777) + end + let!(:route_with_ports_1) do + VCAP::CloudController::Route.make(host: '', space: space, domain: domain_tcp, guid: 'route-with-port-1', port: 8888) + end + let!(:route_with_ports_2) do + VCAP::CloudController::Route.make(host: '', space: space, domain: domain_tcp, guid: 'route-with-port-2', port: 9999) + end + let!(:route_mapping_1) { VCAP::CloudController::RouteMappingModel.make(app: app_model, route: route_with_ports_1, process_type: 'web') } + let!(:route_mapping_2) { VCAP::CloudController::RouteMappingModel.make(app: app_model, route: route_with_ports_2, process_type: 'web') } + + it 'returns routes filtered by ports' do + get "/v3/apps/#{app_model.guid}/routes?ports=7777,8888", nil, admin_header + expect(last_response).to have_status_code(200) + expect(parsed_response['resources'].size).to eq(1) + expect(parsed_response['resources'].first['port']).to eq(route_with_ports_1.port) + end + end + end + + describe 'eager loading' do + it 'eager loads associated resources that the presenter specifies' do + expect(VCAP::CloudController::RouteFetcher).to receive(:fetch).with( + anything, + hash_including(eager_loaded_associations: %i[domain space route_mappings labels annotations]) + ).and_call_original + + get "/v3/apps/#{app_model.guid}/routes", nil, admin_header + expect(last_response).to have_status_code(200) + end + end + end +end diff --git a/spec/request/routes/create_spec.rb b/spec/request/routes/create_spec.rb new file mode 100644 index 00000000000..4d6d709d4c6 --- /dev/null +++ b/spec/request/routes/create_spec.rb @@ -0,0 +1,1290 @@ +require 'spec_helper' +require 'request_spec_shared_examples' +require_relative 'shared_context' + +# Split from spec/request/routes_spec.rb for better test parallelization + +RSpec.describe 'Routes Request' do + include_context 'routes request spec' + + describe 'POST /v3/routes' do + context 'when creating a route in a tcp domain' do + let(:domain) { VCAP::CloudController::SharedDomain.make(router_group_guid: 'some-router-group', router_group_type: 'tcp') } + + before do + token = { token_type: 'Bearer', access_token: 'my-favourite-access-token' } + stub_request(:post, 'https://uaa.service.cf.internal/oauth/token'). + to_return(status: 200, body: token.to_json, headers: { 'Content-Type' => 'application/json' }) + stub_request(:get, 'http://localhost:3000/routing/v1/router_groups'). + to_return(status: 200, body: '[{"guid":"some-router-group","name":"Robby Router","type":"tcp","reservable_ports":"25555"}]', headers: {}) + end + + context 'and the route has a host' do + let(:params) do + { + host: 'my-host', + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + it 'returns a 422 and a helpful error message' do + post '/v3/routes', params.to_json, admin_header + expect(last_response).to have_status_code(422) + expect(last_response).to have_error_message('Hosts are not supported for TCP routes.') + end + end + + context 'and the route has a path' do + let(:params) do + { + path: '/cgi-bin', + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + it 'returns a 422 and a helpful error message' do + post '/v3/routes', params.to_json, admin_header + expect(last_response).to have_status_code(422) + expect(last_response).to have_error_message('Paths are not supported for TCP routes.') + end + end + end + + context 'when creating a route in a scoped domain' do + let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } + + describe 'when creating a route without a host' do + let(:params) do + { + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + let(:route_json) do + { + guid: UUID_REGEX, + protocol: domain.protocols[0], + host: '', + path: '', + port: nil, + url: domain.name, + created_at: iso8601, + updated_at: iso8601, + destinations: [], + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + }, + links: { + self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, + space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}} }, + destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, + domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } + }, + metadata: { + labels: {}, + annotations: {} + }, + options: {} + } + end + + describe 'valid routes' do + let(:api_call) { ->(user_headers) { post '/v3/routes', params.to_json, user_headers } } + + let(:expected_codes_and_responses) do + h = Hash.new( + { code: 403 }.freeze + ) + h['admin'] = { + code: 201, + response_object: route_json + } + h['space_developer'] = { + code: 201, + response_object: route_json + } + h['space_supporter'] = { + code: 201, + response_object: route_json + } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + end + + describe 'when creating a route with a host' do + let(:params) do + { + host: 'some-host', + path: '/some-path', + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + }, + metadata: { + labels: { potato: 'yam' }, + annotations: { style: 'mashed' } + }, + options: {} + } + end + + let(:route_json) do + { + guid: UUID_REGEX, + protocol: domain.protocols[0], + host: 'some-host', + path: '/some-path', + port: nil, + url: "some-host.#{domain.name}/some-path", + created_at: iso8601, + updated_at: iso8601, + destinations: [], + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + }, + links: { + self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, + space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}} }, + destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, + domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } + }, + metadata: { + labels: { potato: 'yam' }, + annotations: { style: 'mashed' } + }, + options: {} + } + end + + describe 'valid routes' do + it_behaves_like 'permissions for single object endpoint', ['admin'] do + let(:api_call) { ->(user_headers) { post '/v3/routes', params.to_json, user_headers } } + + let(:expected_codes_and_responses) do + h = Hash.new( + { code: 403 }.freeze + ) + h['admin'] = { + code: 201, + response_object: route_json + } + h['space_developer'] = { + code: 201, + response_object: route_json + } + h['space_supporter'] = { + code: 201, + response_object: route_json + } + h + end + + let(:expected_event_hash) do + { + type: 'audit.route.create', + actee: parsed_response['guid'], + actee_type: 'route', + actee_name: 'some-host', + metadata: { request: params }.to_json, + space_guid: space.guid, + organization_guid: org.guid + } + end + end + end + end + + describe 'when creating a route with a wildcard host' do + let(:params) do + { + host: '*', + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + let(:route_json) do + { + guid: UUID_REGEX, + protocol: domain.protocols[0], + host: '*', + path: '', + port: nil, + url: "*.#{domain.name}", + created_at: iso8601, + updated_at: iso8601, + destinations: [], + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + }, + links: { + self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, + space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}} }, + destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, + domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } + }, + metadata: { + labels: {}, + annotations: {} + }, + options: {} + } + end + + describe 'valid routes' do + let(:api_call) { ->(user_headers) { post '/v3/routes', params.to_json, user_headers } } + + let(:expected_codes_and_responses) do + h = Hash.new( + { code: 403 }.freeze + ) + h['admin'] = { + code: 201, + response_object: route_json + } + h['space_developer'] = { + code: 201, + response_object: route_json + } + h['space_supporter'] = { + code: 201, + response_object: route_json + } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + end + end + + context 'when creating a route in an unscoped domain' do + let(:domain) { VCAP::CloudController::SharedDomain.make } + + describe 'when creating a route without a host' do + let(:params) do + { + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + it 'fails with a helpful message' do + post '/v3/routes', params.to_json, admin_header + expect(last_response).to have_status_code(422) + expect(last_response).to have_error_message('Missing host. Routes in shared domains must have a host defined.') + end + end + + describe 'when creating a route with a host' do + let(:params) do + { + host: 'some-host', + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + let(:route_json) do + { + guid: UUID_REGEX, + protocol: domain.protocols[0], + host: 'some-host', + path: '', + port: nil, + url: "some-host.#{domain.name}", + created_at: iso8601, + updated_at: iso8601, + destinations: [], + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + }, + links: { + self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, + space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}} }, + destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, + domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } + }, + metadata: { + labels: {}, + annotations: {} + }, + options: {} + } + end + + describe 'valid routes' do + let(:api_call) { ->(user_headers) { post '/v3/routes', params.to_json, user_headers } } + + let(:expected_codes_and_responses) do + h = Hash.new( + { code: 403 }.freeze + ) + h['admin'] = { + code: 201, + response_object: route_json + } + h['space_developer'] = { + code: 201, + response_object: route_json + } + h['space_supporter'] = { + code: 201, + response_object: route_json + } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + end + + describe 'when creating a route with a wildcard host' do + let(:params) do + { + host: '*', + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + let(:route_json) do + { + guid: UUID_REGEX, + protocol: domain.protocols[0], + host: '*', + path: '', + port: nil, + url: "*.#{domain.name}", + created_at: iso8601, + updated_at: iso8601, + destinations: [], + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + }, + links: { + self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, + space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}} }, + destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, + domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } + }, + metadata: { + labels: {}, + annotations: {} + }, + options: {} + } + end + + describe 'valid routes' do + let(:api_call) { ->(user_headers) { post '/v3/routes', params.to_json, user_headers } } + + let(:expected_codes_and_responses) do + h = Hash.new( + { code: 403 }.freeze + ) + h['admin'] = { + code: 201, + response_object: route_json + } + h['space_developer'] = { + code: 422 + } + h['space_supporter'] = { + code: 422 + } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + end + + describe 'the domain supports tcp routes' do + let(:router_group) { VCAP::CloudController::RoutingApi::RouterGroup.new({ 'type' => 'tcp', 'reservable_ports' => '123' }) } + let(:domain) { VCAP::CloudController::SharedDomain.make(router_group_guid: 'some-router-group', name: 'my.domain') } + let(:routing_api_client) { instance_double(VCAP::CloudController::RoutingApi::Client) } + + before do + TestConfig.override( + kubernetes: { host_url: nil }, + external_domain: 'api2.vcap.me', + external_protocol: 'https' + ) + allow_any_instance_of(CloudController::DependencyLocator).to receive(:routing_api_client).and_return(routing_api_client) + allow(routing_api_client).to receive_messages(enabled?: true, router_group: router_group) + end + + let(:params) do + { + port: 123, + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + let(:api_call) { ->(user_headers) { post '/v3/routes', params.to_json, user_headers } } + + let(:route_json) do + { + guid: UUID_REGEX, + port: 123, + host: '', + path: '', + protocol: 'tcp', + url: "#{domain.name}:123", + created_at: iso8601, + updated_at: iso8601, + destinations: [], + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + }, + links: { + self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, + space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}} }, + destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, + domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } + }, + metadata: { + labels: {}, + annotations: {} + }, + options: {} + } + end + + let(:expected_codes_and_responses) do + h = Hash.new( + { code: 403 }.freeze + ) + h['admin'] = { + code: 201, + response_object: route_json + } + h['space_developer'] = { + code: 201, + response_object: route_json + } + h['space_supporter'] = { + code: 201, + response_object: route_json + } + h + end + + context 'and the user provides a valid port' do + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + + context 'and a route with the domain and port already exist' do + let!(:duplicate_route) { VCAP::CloudController::Route.make(host: '', space: space, domain: domain, port: 123) } + + it 'fails with a helpful error message' do + post '/v3/routes', params.to_json, admin_headers + expect(last_response).to have_status_code(422) + expect(last_response).to have_error_message("Route already exists with port '123' for domain 'my.domain'.") + end + end + + context 'and the port is already in use for the router group' do + let!(:other_domain) { VCAP::CloudController::SharedDomain.make(router_group_guid: 'some-router-group', name: 'my.domain2') } + let!(:route_with_port) { VCAP::CloudController::Route.make(host: '', space: space, domain: other_domain, port: 123) } + + it 'fails with a helpful error message' do + post '/v3/routes', params.to_json, admin_headers + expect(last_response).to have_status_code(422) + expect(last_response).to have_error_message("Port '123' is not available. Try a different port or use a different domain.") + end + end + end + + context 'and the user does not provide a port' do + let(:params) do + { + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + + context 'and randomly selected port is already in use' do + let(:existing_route) { VCAP::CloudController::Route.make(host: '', space: space, domain: domain, port: 123) } + + let(:params) do + { + port: existing_route.port, + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + it 'fails with a helpful error message' do + post '/v3/routes', params.to_json, admin_headers + expect(last_response).to have_status_code(422) + expect(last_response).to have_error_message("Route already exists with port '123' for domain 'my.domain'.") + end + end + end + end + end + + context 'when creating a route in a suspended org' do + before do + org.update(status: VCAP::CloudController::Organization::SUSPENDED) + end + + let(:domain) { VCAP::CloudController::SharedDomain.make } + + let(:params) do + { + host: 'some-host', + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + let(:route_json) do + { + guid: UUID_REGEX, + protocol: domain.protocols[0], + host: 'some-host', + path: '', + port: nil, + url: "some-host.#{domain.name}", + created_at: iso8601, + updated_at: iso8601, + destinations: [], + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + }, + metadata: { + labels: {}, + annotations: {} + }, + links: { + self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, + space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}} }, + destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, + domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } + }, + options: {} + } + end + + describe 'valid routes' do + let(:api_call) { ->(user_headers) { post '/v3/routes', params.to_json, user_headers } } + + let(:expected_codes_and_responses) do + h = Hash.new({ code: 403, errors: CF_NOT_AUTHORIZED }.freeze) + h['admin'] = { + code: 201, + response_object: route_json + } + %w[space_developer space_supporter].each { |r| h[r] = { code: 403, errors: CF_ORG_SUSPENDED } } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + end + + context 'when creating a route in an internal domain' do + let(:domain) { VCAP::CloudController::SharedDomain.make(internal: true) } + + describe 'when creating a route with a wildcard host' do + let(:params) do + { + host: '*', + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + it 'fails with a helpful message' do + post '/v3/routes', params.to_json, admin_header + expect(last_response).to have_status_code(422) + expect(last_response).to have_error_message('Wildcard hosts are not supported for internal domains.') + end + end + + describe 'when creating a route with a path' do + let(:params) do + { + host: 'host', + path: '/apath', + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + it 'fails with a helpful message' do + post '/v3/routes', params.to_json, admin_header + expect(last_response).to have_status_code(422) + expect(last_response).to have_error_message('Paths are not supported for internal domains.') + end + end + + describe 'when creating a route with a host' do + let(:params) do + { + host: 'some-host', + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + let(:route_json) do + { + guid: UUID_REGEX, + protocol: domain.protocols[0], + host: 'some-host', + path: '', + port: nil, + url: "some-host.#{domain.name}", + created_at: iso8601, + updated_at: iso8601, + destinations: [], + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + }, + links: { + self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, + space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}} }, + destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, + domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } + }, + metadata: { + labels: {}, + annotations: {} + }, + options: {} + } + end + + describe 'valid routes' do + let(:api_call) { ->(user_headers) { post '/v3/routes', params.to_json, user_headers } } + + let(:expected_codes_and_responses) do + h = Hash.new( + { code: 403 }.freeze + ) + h['admin'] = { + code: 201, + response_object: route_json + } + h['space_developer'] = { + code: 201, + response_object: route_json + } + h['space_supporter'] = { + code: 201, + response_object: route_json + } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + end + end + + context 'when the domain has an owning org that is different from the space\'s parent org' do + let(:other_org) { VCAP::CloudController::Organization.make } + let(:inaccessible_domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: other_org) } + + let(:params_with_inaccessible_domain) do + { + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: inaccessible_domain.guid } + } + } + } + end + + it 'returns a 422 with a helpful error message' do + post '/v3/routes', params_with_inaccessible_domain.to_json, admin_header + expect(last_response).to have_status_code(422) + expect(last_response).to have_error_message("Invalid domain. Domain '#{inaccessible_domain.name}' is not available in organization '#{org.name}'.") + end + end + + context 'when the host-less route has already been created for this domain' do + let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } + let!(:existing_route) { VCAP::CloudController::Route.make(host: '', space: space, domain: domain) } + + let(:params_for_duplicate_route) do + { + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + it 'returns a 422 with a helpful error message' do + post '/v3/routes', params_for_duplicate_route.to_json, admin_header + expect(last_response).to have_status_code(422) + expect(last_response).to have_error_message("Route already exists for domain '#{domain.name}'.") + end + end + + context 'when there is already a route' do + context 'with the host/domain/path combination' do + let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } + let!(:existing_route) { VCAP::CloudController::Route.make(host: 'my-host', path: '/existing', space: space, domain: domain) } + + let(:params_for_duplicate_route) do + { + host: existing_route.host, + path: existing_route.path, + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + it 'returns a 422 with a helpful error message' do + post '/v3/routes', params_for_duplicate_route.to_json, admin_header + expect(last_response).to have_status_code(422) + expect(last_response).to have_error_message("Route already exists with host '#{existing_route.host}' and path '#{existing_route.path}' for domain '#{domain.name}'.") + end + end + + context 'with the host/domain combination' do + let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } + let!(:existing_route) { VCAP::CloudController::Route.make(host: 'my-host', space: space, domain: domain) } + + let(:params_for_duplicate_route) do + { + host: existing_route.host, + path: existing_route.path, + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + it 'returns a 422 with a helpful error message' do + post '/v3/routes', params_for_duplicate_route.to_json, admin_header + expect(last_response).to have_status_code(422) + expect(last_response).to have_error_message("Route already exists with host '#{existing_route.host}' for domain '#{domain.name}'.") + end + end + end + + context 'when there is already a domain matching the host/domain combination' do + let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } + let!(:existing_domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization, name: "#{params[:host]}.#{domain.name}") } + + let(:params) do + { + host: 'some-host', + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + it 'returns a 422 with a helpful error message' do + post '/v3/routes', params.to_json, admin_header + expect(last_response).to have_status_code(422) + expect(last_response).to have_error_message("Route conflicts with domain '#{existing_domain.name}'.") + end + end + + context 'when using a reserved system hostname with the system domain' do + let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } + + let(:params) do + { + host: 'host', + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + before do + VCAP::CloudController::Config.config.set(:system_domain, domain.name) + VCAP::CloudController::Config.config.set(:system_hostnames, [params[:host]]) + end + + it 'returns a 422 with a helpful error message' do + post '/v3/routes', params.to_json, admin_header + expect(last_response).to have_status_code(422) + expect(last_response).to have_error_message('Route conflicts with a reserved system route.') + end + end + + context 'when using a non-reserved hostname with the system domain' do + let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } + let(:api_call) { ->(user_headers) { post '/v3/routes', params.to_json, user_headers } } + + let(:params) do + { + host: 'host', + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + let(:route_json) do + { + guid: UUID_REGEX, + protocol: domain.protocols[0], + host: params[:host], + path: '', + port: nil, + url: "#{params[:host]}.#{domain.name}", + created_at: iso8601, + updated_at: iso8601, + destinations: [], + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + }, + metadata: { + labels: {}, + annotations: {} + }, + links: { + self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, + space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}} }, + destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, + domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } + }, + options: {} + } + end + + let(:expected_codes_and_responses) do + h = Hash.new( + { code: 403 }.freeze + ) + h['admin'] = { + code: 201, + response_object: route_json + } + h['space_developer'] = { + code: 201, + response_object: route_json + } + h['space_supporter'] = { + code: 201, + response_object: route_json + } + h + end + + before do + VCAP::CloudController::Config.config.set(:system_domain, domain.name) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + + describe 'quotas' do + context 'when the space quota for routes is maxed out' do + let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } + let!(:space_quota_definition) { VCAP::CloudController::SpaceQuotaDefinition.make(total_routes: 0, organization: org) } + let!(:space_with_quota) { VCAP::CloudController::Space.make(space_quota_definition: space_quota_definition, organization: org) } + + let(:params_for_space_with_quota) do + { + relationships: { + space: { + data: { guid: space_with_quota.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + it 'returns a 422 with a helpful error message' do + post '/v3/routes', params_for_space_with_quota.to_json, admin_header + expect(last_response).to have_status_code(422) + expect(last_response).to have_error_message("Routes quota exceeded for space '#{space_with_quota.name}'.") + end + end + + context 'when the org quota for routes is maxed out' do + let!(:org_quota_definition) { VCAP::CloudController::QuotaDefinition.make(total_routes: 0, total_reserved_route_ports: 0) } + let!(:org_with_quota) { VCAP::CloudController::Organization.make(quota_definition: org_quota_definition) } + let!(:space_in_org_with_quota) do + VCAP::CloudController::Space.make(organization: org_with_quota) + end + let(:domain_in_org_with_quota) { VCAP::CloudController::Domain.make(owning_organization: org_with_quota) } + + let(:params_for_org_with_quota) do + { + relationships: { + space: { + data: { guid: space_in_org_with_quota.guid } + }, + domain: { + data: { guid: domain_in_org_with_quota.guid } + } + } + } + end + + it 'returns a 422 with a helpful error message' do + post '/v3/routes', params_for_org_with_quota.to_json, admin_header + expect(last_response).to have_status_code(422) + expect(last_response).to have_error_message("Routes quota exceeded for organization '#{org_with_quota.name}'.") + end + end + end + + context 'when the feature flag is disabled' do + let(:headers) { set_user_with_header_as_role(user: user, role: 'space_developer', org: org, space: space) } + let!(:feature_flag) { VCAP::CloudController::FeatureFlag.make(name: 'route_creation', enabled: false, error_message: 'my name is bob') } + let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } + let(:params) do + { + host: 'some-host', + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + context 'when the user is not an admin' do + it 'returns a 403' do + post '/v3/routes', params.to_json, headers + + expect(last_response).to have_status_code(403) + expect(parsed_response['errors'][0]['detail']).to eq('Feature Disabled: my name is bob') + end + end + + context 'when the user is an admin' do + let(:headers) { set_user_with_header_as_role(role: 'admin') } + + it 'allows creation' do + post '/v3/routes', params.to_json, headers + + expect(last_response).to have_status_code(201) + end + end + end + + context 'when the user is not logged in' do + it 'returns 401 for Unauthenticated requests' do + post '/v3/routes', {}.to_json, base_json_headers + expect(last_response).to have_status_code(401) + end + end + + context 'when the user does not have the required scopes' do + let(:user_header) { headers_for(user, scopes: ['cloud_controller.read']) } + + it 'returns a 403' do + post '/v3/routes', {}.to_json, user_header + expect(last_response).to have_status_code(403) + end + end + + context 'when the space does not exist' do + let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } + + let(:params_with_invalid_space) do + { + relationships: { + space: { + data: { guid: 'invalid-space' } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + it 'returns a 422 with a helpful error message' do + post '/v3/routes', params_with_invalid_space.to_json, admin_header + expect(last_response).to have_status_code(422) + expect(last_response).to have_error_message('Invalid space. Ensure that the space exists and you have access to it.') + end + end + + context 'when the domain does not exist' do + let(:params_with_invalid_domain) do + { + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: 'invalid-domain' } + } + } + } + end + + it 'returns a 422 with a helpful error message' do + post '/v3/routes', params_with_invalid_domain.to_json, admin_header + expect(last_response).to have_status_code(422) + expect(last_response).to have_error_message('Invalid domain. Ensure that the domain exists and you have access to it.') + end + end + + context 'when communicating with the routing API' do + let(:routing_api_client) { instance_double(VCAP::CloudController::RoutingApi::Client) } + let(:router_group) { VCAP::CloudController::RoutingApi::RouterGroup.new({ 'type' => 'tcp', 'guid' => 'some-guid' }) } + let(:headers) { set_user_with_header_as_role(role: 'admin') } + let(:domain_tcp) { VCAP::CloudController::SharedDomain.make(router_group_guid: router_group.guid, name: 'my.domain') } + let(:params) do + { + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain_tcp.guid } + } + } + } + end + + before do + allow_any_instance_of(CloudController::DependencyLocator).to receive(:routing_api_client).and_return(routing_api_client) + end + + context 'when UAA is unavailable' do + before do + allow(routing_api_client).to receive(:router_group).and_raise VCAP::CloudController::RoutingApi::UaaUnavailable + end + + it 'returns a 503 with a helpful error message' do + post '/v3/routes', params.to_json, headers + + expect(last_response).to have_status_code(503) + expect(parsed_response['errors'][0]['detail']).to eq 'Communicating with the Routing API failed because UAA is currently unavailable. Please try again later.' + end + end + + context 'when the routing API is unavailable' do + before do + allow(routing_api_client).to receive(:enabled?).and_return true + allow(routing_api_client).to receive(:router_group).and_raise VCAP::CloudController::RoutingApi::RoutingApiUnavailable + end + + it 'returns a 503 with a helpful error message' do + post '/v3/routes', params.to_json, headers + + expect(last_response).to have_status_code(503) + expect(parsed_response['errors'][0]['detail']).to eq 'The Routing API is currently unavailable. Please try again later.' + end + end + + context 'when the routing API is disabled' do + before do + allow(routing_api_client).to receive(:enabled?).and_return false + allow(routing_api_client).to receive(:router_group).and_raise VCAP::CloudController::RoutingApi::RoutingApiDisabled + end + + it 'returns a 503 with a helpful error message' do + post '/v3/routes', params.to_json, headers + + expect(last_response).to have_status_code(503) + expect(parsed_response['errors'][0]['detail']).to eq 'The Routing API is disabled.' + end + end + + context 'when the router group is unavailable' do + let(:domain_tcp) { VCAP::CloudController::SharedDomain.make(router_group_guid: 'not a guid', name: 'my.domain') } + + before do + allow(routing_api_client).to receive_messages(enabled?: true, router_group: nil) + end + + it 'returns a 503 with a helpful error message' do + post '/v3/routes', params.to_json, headers + + expect(last_response.status).to eq(422) + expect(parsed_response['errors'][0]['detail']).to eq 'Route could not be created because the specified domain does not have a valid router group.' + end + end + end + end +end diff --git a/spec/request/routes/list_spec.rb b/spec/request/routes/list_spec.rb new file mode 100644 index 00000000000..4a987141e3a --- /dev/null +++ b/spec/request/routes/list_spec.rb @@ -0,0 +1,938 @@ +require 'spec_helper' +require 'request_spec_shared_examples' +require_relative 'shared_context' + +# Split from spec/request/routes_spec.rb for better test parallelization + +RSpec.describe 'Routes Request' do + include_context 'routes request spec' + + describe 'GET /v3/routes' do + let(:other_space) { VCAP::CloudController::Space.make(name: 'b-space') } + let(:app_model) { VCAP::CloudController::AppModel.make(space:) } + let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } + let!(:route_in_org) do + VCAP::CloudController::Route.make(space: space, domain: domain, host: 'host-1', path: '/path1', guid: 'route-in-org-guid') + end + let!(:route_in_other_org) do + VCAP::CloudController::Route.make(space: other_space, host: 'host-2', path: '/path2', guid: 'route-in-other-org-guid') + end + let!(:route_in_org_dest_web) { VCAP::CloudController::RouteMappingModel.make(app: app_model, route: route_in_org, process_type: 'web') } + let!(:route_in_org_dest_worker) { VCAP::CloudController::RouteMappingModel.make(app: app_model, route: route_in_org, process_type: 'worker') } + let(:api_call) { ->(user_headers) { get '/v3/routes', nil, user_headers } } + let(:route_in_org_json) do + { + guid: route_in_org.guid, + protocol: route_in_org.domain.protocols[0], + host: route_in_org.host, + path: route_in_org.path, + port: nil, + url: "#{route_in_org.host}.#{route_in_org.domain.name}#{route_in_org.path}", + created_at: iso8601, + updated_at: iso8601, + destinations: contain_exactly({ + guid: route_in_org_dest_web.guid, + app: { + guid: app_model.guid, + process: { + type: route_in_org_dest_web.process_type + } + }, + weight: route_in_org_dest_web.weight, + port: route_in_org_dest_web.presented_port, + protocol: 'http1', + created_at: iso8601, + updated_at: iso8601 + }, { + guid: route_in_org_dest_worker.guid, + app: { + guid: app_model.guid, + process: { + type: route_in_org_dest_worker.process_type + } + }, + weight: route_in_org_dest_worker.weight, + port: route_in_org_dest_worker.presented_port, + protocol: 'http1', + created_at: iso8601, + updated_at: iso8601 + }), + relationships: { + space: { + data: { guid: route_in_org.space.guid } + }, + domain: { + data: { guid: route_in_org.domain.guid } + } + }, + metadata: { + labels: {}, + annotations: {} + }, + options: {}, + links: { + self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{route_in_org.guid}} }, + space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{route_in_org.space.guid}} }, + destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{route_in_org.guid}/destinations} }, + domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{route_in_org.domain.guid}} } + } + } + end + + let(:route_in_other_org_json) do + { + guid: route_in_other_org.guid, + protocol: route_in_other_org.domain.protocols[0], + host: route_in_other_org.host, + path: route_in_other_org.path, + port: nil, + url: "#{route_in_other_org.host}.#{route_in_other_org.domain.name}#{route_in_other_org.path}", + created_at: iso8601, + updated_at: iso8601, + destinations: [], + relationships: { + space: { + data: { guid: route_in_other_org.space.guid } + }, + domain: { + data: { guid: route_in_other_org.domain.guid } + } + }, + metadata: { + labels: {}, + annotations: {} + }, + options: {}, + links: { + self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{route_in_other_org.guid}} }, + space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{route_in_other_org.space.guid}} }, + destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{route_in_other_org.guid}/destinations} }, + domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{route_in_other_org.domain.guid}} } + } + } + end + + it_behaves_like 'list_endpoint_with_common_filters' do + let(:resource_klass) { VCAP::CloudController::Route } + let(:api_call) do + ->(headers, filters) { get "/v3/routes?#{filters}", nil, headers } + end + let(:headers) { admin_headers } + end + + describe 'query list parameters' do + it_behaves_like 'list query endpoint' do + let(:request) { 'v3/routes' } + let(:message) { VCAP::CloudController::RoutesListMessage } + let(:user_header) { admin_header } + + let(:params) do + { + page: '2', + per_page: '10', + order_by: 'updated_at', + space_guids: %w[foo bar], + service_instance_guids: %w[baz qux], + organization_guids: %w[foo bar], + domain_guids: %w[foo bar], + app_guids: %w[foo bar], + guids: %w[foo bar], + paths: %w[foo bar], + hosts: 'foo', + ports: 636, + include: 'domain', + label_selector: 'foo,bar', + created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", + updated_ats: { gt: Time.now.utc.iso8601 } + } + end + end + end + + context 'when the user is a member in the routes org' do + let(:expected_codes_and_responses) do + h = Hash.new( + { code: 200, + response_objects: [route_in_org_json] }.freeze + ) + + h['admin'] = { code: 200, response_objects: [route_in_org_json, route_in_other_org_json] } + h['admin_read_only'] = { code: 200, response_objects: [route_in_org_json, route_in_other_org_json] } + h['global_auditor'] = { code: 200, response_objects: [route_in_org_json, route_in_other_org_json] } + + h['org_billing_manager'] = { code: 200, response_objects: [] } + h['no_role'] = { code: 200, response_objects: [] } + h + end + + it_behaves_like 'permissions for list endpoint', ALL_PERMISSIONS + end + + describe 'includes' do + context 'when including domains' do + let(:domain1) { VCAP::CloudController::SharedDomain.make(name: 'first-domain.example.com') } + let(:domain1_json) do + { + guid: domain1.guid, + created_at: iso8601, + updated_at: iso8601, + name: domain1.name, + internal: false, + router_group: nil, + supported_protocols: ['http'], + metadata: { + labels: {}, + annotations: {} + }, + relationships: { + organization: { + data: nil + }, + shared_organizations: { + data: [] + } + }, + links: { + self: { href: "#{link_prefix}/v3/domains/#{domain1.guid}" }, + route_reservations: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain1.guid}/route_reservations} } + } + } + end + + let(:domain2) { VCAP::CloudController::SharedDomain.make(name: 'second-domain.example.com') } + let(:domain2_json) do + { + guid: domain2.guid, + created_at: iso8601, + updated_at: iso8601, + name: domain2.name, + internal: false, + router_group: nil, + supported_protocols: ['http'], + metadata: { + labels: {}, + annotations: {} + }, + relationships: { + organization: { + data: nil + }, + shared_organizations: { + data: [] + } + }, + links: { + self: { href: "#{link_prefix}/v3/domains/#{domain1.guid}" }, + route_reservations: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain1.guid}/route_reservations} } + } + } + end + + let(:domain2) { VCAP::CloudController::SharedDomain.make(name: 'second-domain.example.com') } + let(:domain2_json) do + { + guid: domain2.guid, + created_at: iso8601, + updated_at: iso8601, + name: domain2.name, + internal: false, + router_group: nil, + supported_protocols: ['http'], + metadata: { + labels: {}, + annotations: {} + }, + relationships: { + organization: { + data: nil + }, + shared_organizations: { + data: [] + } + }, + links: { + self: { href: "#{link_prefix}/v3/domains/#{domain2.guid}" }, + route_reservations: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain2.guid}/route_reservations} } + } + } + end + + let!(:route1_domain1) do + VCAP::CloudController::Route.make(space: space, host: 'route1', domain: domain1, path: '/path1', guid: 'route1-guid') + end + let(:route1_domain1_json) do + { + guid: route1_domain1.guid, + protocol: route1_domain1.domain.protocols[0], + created_at: iso8601, + updated_at: iso8601, + host: route1_domain1.host, + path: route1_domain1.path, + port: nil, + url: "#{route1_domain1.host}.#{domain1.name}#{route1_domain1.path}", + destinations: [], + metadata: { + labels: {}, + annotations: {} + }, + relationships: { + space: { + data: { + guid: space.guid + } + }, + domain: { + data: { + guid: domain1.guid + } + } + }, + options: {}, + links: { + self: { href: "http://api2.vcap.me/v3/routes/#{route1_domain1.guid}" }, + space: { href: "http://api2.vcap.me/v3/spaces/#{space.guid}" }, + destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{route1_domain1.guid}/destinations} }, + domain: { href: "http://api2.vcap.me/v3/domains/#{domain1.guid}" } + } + } + end + + let!(:route_in_org) do + VCAP::CloudController::Route.make(space: space, domain: domain1, host: 'host-1', path: '/path1', guid: 'route-in-org-guid') + end + let!(:route_in_other_org) do + VCAP::CloudController::Route.make(space: other_space, domain: domain2, host: 'host-2', path: '/path2', guid: 'route-in-other-org-guid') + end + + it 'includes the unique domains for the routes' do + get '/v3/routes?include=domain', nil, admin_header + expect(last_response).to have_status_code(200) + expect({ + resources: parsed_response['resources'], + included: parsed_response['included'] + }).to match_json_response({ + resources: [route_in_org_json, route_in_other_org_json, route1_domain1_json], + included: { 'domains' => [domain1_json, domain2_json] } + }) + end + end + + context 'when including spaces and orgs' do + it 'includes the unique spaces and organizations for the routes' do + get '/v3/routes?include=space,space.organization', nil, admin_header + expect(last_response).to have_status_code(200) + expect({ + resources: parsed_response['resources'], + included: parsed_response['included'] + }).to match_json_response({ + resources: [route_in_org_json, route_in_other_org_json], + included: { + 'spaces' => [ + space_json_generator.call(space), + space_json_generator.call(other_space) + ], + 'organizations' => [ + org_json_generator.call(org), + org_json_generator.call(other_space.organization) + ] + } + }) + end + end + + context 'when including spaces' do + it 'eagerly loads spaces to efficiently access space_guid' do + expect(VCAP::CloudController::IncludeSpaceDecorator).to receive(:decorate) do |_, resources| + expect(resources).not_to be_empty + resources.each { |r| expect(r.associations).to include(:space) } + end + + get '/v3/routes?include=space', nil, admin_header + expect(last_response).to have_status_code(200) + end + end + + context 'when including orgs' do + it 'eagerly loads spaces to efficiently access space.organization_id' do + expect(VCAP::CloudController::IncludeOrganizationDecorator).to receive(:decorate) do |_, resources| + expect(resources).not_to be_empty + resources.each { |r| expect(r.associations).to include(:space) } + end + + get '/v3/routes?include=space.organization', nil, admin_header + expect(last_response).to have_status_code(200) + end + end + end + + describe 'filters' do + let!(:route_without_host_and_with_path) do + VCAP::CloudController::Route.make(space: space, host: '', domain: domain, path: '/path1', guid: 'route-without-host') + end + let!(:route_without_host_and_with_path2) do + VCAP::CloudController::Route.make(space: space, host: '', domain: domain, path: '/path2', guid: 'route-without-host2') + end + let(:route_without_host_and_with_path_json) do + { + guid: 'route-without-host', + protocol: domain.protocols[0], + created_at: iso8601, + updated_at: iso8601, + destinations: [], + host: '', + path: '/path1', + port: nil, + url: "#{domain.name}/path1", + metadata: { + labels: {}, + annotations: {} + }, + relationships: { + space: { + data: { + guid: space.guid + } + }, + domain: { + data: { + guid: domain.guid + } + } + }, + options: {}, + links: { + self: { href: 'http://api2.vcap.me/v3/routes/route-without-host' }, + space: { href: "http://api2.vcap.me/v3/spaces/#{space.guid}" }, + destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/route-without-host/destinations} }, + domain: { href: "http://api2.vcap.me/v3/domains/#{domain.guid}" } + } + } + end + let(:route_without_host_and_with_path2_json) do + { + guid: 'route-without-host2', + protocol: domain.protocols[0], + created_at: iso8601, + updated_at: iso8601, + destinations: [], + host: '', + path: '/path2', + port: nil, + url: "#{domain.name}/path2", + metadata: { + labels: {}, + annotations: {} + }, + relationships: { + space: { + data: { + guid: space.guid + } + }, + domain: { + data: { + guid: domain.guid + } + } + }, + options: {}, + links: { + self: { href: 'http://api2.vcap.me/v3/routes/route-without-host2' }, + space: { href: "http://api2.vcap.me/v3/spaces/#{space.guid}" }, + destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/route-without-host2/destinations} }, + domain: { href: "http://api2.vcap.me/v3/domains/#{domain.guid}" } + } + } + end + let!(:route_without_path_and_with_host) do + VCAP::CloudController::Route.make(space: space, host: 'host-1', domain: domain, path: '', guid: 'route-without-path') + end + let(:route_without_path_and_with_host_json) do + { + guid: 'route-without-path', + protocol: domain.protocols[0], + created_at: iso8601, + updated_at: iso8601, + destinations: [], + host: 'host-1', + path: '', + port: nil, + url: "host-1.#{domain.name}", + metadata: { + labels: {}, + annotations: {} + }, + relationships: { + space: { + data: { + guid: space.guid + } + }, + domain: { + data: { + guid: domain.guid + } + } + }, + options: {}, + links: { + self: { href: 'http://api2.vcap.me/v3/routes/route-without-path' }, + space: { href: "http://api2.vcap.me/v3/spaces/#{space.guid}" }, + destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/route-without-path/destinations} }, + domain: { href: "http://api2.vcap.me/v3/domains/#{domain.guid}" } + } + } + end + + context 'hosts filter' do + it 'returns routes filtered by host' do + get '/v3/routes?hosts=host-1', nil, admin_header + expect(last_response).to have_status_code(200) + expect({ + resources: parsed_response['resources'] + }).to match_json_response({ + resources: [route_in_org_json, route_without_path_and_with_host_json] + }) + end + + it 'returns route with no host if one exists when filtering by empty host' do + get '/v3/routes?hosts=', nil, admin_header + expect(last_response).to have_status_code(200) + expect({ + resources: parsed_response['resources'] + }).to match_json_response({ + resources: [route_without_host_and_with_path_json, route_without_host_and_with_path2_json] + }) + end + end + + context 'paths filter' do + it 'returns routes filtered by path' do + get '/v3/routes?paths=%2Fpath1', nil, admin_header + expect(last_response).to have_status_code(200) + expect({ + resources: parsed_response['resources'] + }).to match_json_response({ + resources: [route_in_org_json, route_without_host_and_with_path_json] + }) + end + + it 'returns route with no path when filtering by empty path' do + get '/v3/routes?paths=', nil, admin_header + expect(last_response).to have_status_code(200) + expect({ + resources: parsed_response['resources'] + }).to match_json_response({ + resources: [route_without_path_and_with_host_json] + }) + end + end + + context 'hosts and paths filter' do + it 'returns routes with no host and the provided path when host is empty' do + get '/v3/routes?paths=%2Fpath1&hosts=', nil, admin_header + expect(last_response).to have_status_code(200) + expect({ + resources: parsed_response['resources'] + }).to match_json_response({ + resources: [route_without_host_and_with_path_json] + }) + end + end + + context 'organization_guids filter' do + it 'returns routes filtered by organization_guid' do + get "/v3/routes?organization_guids=#{other_space.organization.guid}", nil, admin_header + expect(last_response).to have_status_code(200) + expect({ + resources: parsed_response['resources'] + }).to match_json_response({ + resources: [route_in_other_org_json] + }) + end + end + + context 'space_guids filter' do + it 'returns routes filtered by space_guid' do + get "/v3/routes?space_guids=#{other_space.guid}", nil, admin_header + expect(last_response).to have_status_code(200) + expect({ + resources: parsed_response['resources'] + }).to match_json_response({ + resources: [route_in_other_org_json] + }) + end + end + + context 'domain_guids filter' do + it 'returns routes filtered by domain_guid' do + get "/v3/routes?domain_guids=#{route_in_other_org.domain.guid}", nil, admin_header + expect(last_response).to have_status_code(200) + expect({ + resources: parsed_response['resources'] + }).to match_json_response({ + resources: [route_in_other_org_json] + }) + end + end + + context 'app_guids filter' do + it 'returns routes filtered by app_guid' do + get "/v3/routes?app_guids=#{app_model.guid}", nil, admin_header + expect(last_response).to have_status_code(200) + expect(parsed_response['resources'].size).to eq(1) + expect(parsed_response['resources'].first['destinations'].size).to eq(2) + expect( + parsed_response['resources'].first['destinations'].map { |destination| destination['app']['guid'] }.uniq + ).to eq([app_model.guid]) + end + end + + context 'ports filter' do + # Don't even think of converting the following hash to symbols ('type' => 'tcp' NOT type: 'tcp'), and you need to set the GUID + let(:router_group) { VCAP::CloudController::RoutingApi::RouterGroup.new({ 'type' => 'tcp', 'reservable_ports' => '7777,8888,9999', 'guid' => 'some-guid' }) } + let(:routing_api_client) { instance_double(VCAP::CloudController::RoutingApi::Client) } + + before do + allow_any_instance_of(CloudController::DependencyLocator).to receive(:routing_api_client).and_return(routing_api_client) + allow(routing_api_client).to receive_messages(enabled?: true, router_group: router_group) + end + + context 'when there are multiple TCP routes with different ports' do + # The following `let`s depend on the above `before do` + let(:domain_tcp) { VCAP::CloudController::SharedDomain.make(router_group_guid: router_group.guid, name: 'my.domain') } + let!(:route_with_ports_0) do + VCAP::CloudController::Route.make(host: '', space: space, domain: domain_tcp, guid: 'route-with-port-0', port: 7777) + end + let!(:route_with_ports_1) do + VCAP::CloudController::Route.make(host: '', space: space, domain: domain_tcp, guid: 'route-with-port-1', port: 8888) + end + let!(:route_with_ports_2) do + VCAP::CloudController::Route.make(host: '', space: space, domain: domain_tcp, guid: 'route-with-port-2', port: 9999) + end + + it 'returns routes filtered by ports' do + get '/v3/routes?ports=7777,8888', nil, admin_header + expect(last_response).to have_status_code(200) + expect(parsed_response['resources'].size).to eq(2) + expect(parsed_response['resources'].pluck('port')).to contain_exactly(route_with_ports_0.port, route_with_ports_1.port) + end + end + end + + context 'service instance guids filter' do + let(:service_instance_one) do + VCAP::CloudController::ManagedServiceInstance.make(:routing, space: space, name: 'si-name-1') + end + let(:service_instance_two) do + VCAP::CloudController::ManagedServiceInstance.make(:routing, space: space, name: 'si-name-2') + end + + let!(:route_with_service_instance_one) do + VCAP::CloudController::Route.make(space: space, host: 'host-with-service-instance-one', domain: domain, path: '/path1', guid: 'route-with-service-instance-one') + end + let!(:route_with_service_instance_two) do + VCAP::CloudController::Route.make(space: space, host: 'host-with-service-instance-two', domain: domain, path: '/path2', guid: 'route-with-service-instance-two') + end + + let!(:route_mapping_one) { VCAP::CloudController::RouteBinding.make(route: route_with_service_instance_one, service_instance: service_instance_one) } + let!(:route_mapping_two) { VCAP::CloudController::RouteBinding.make(route: route_with_service_instance_two, service_instance: service_instance_two) } + + it 'returns routes filtered by service instance guid' do + get "/v3/routes?service_instance_guids=#{service_instance_one.guid},#{service_instance_two.guid}", nil, admin_header + expect(last_response).to have_status_code(200) + expect(parsed_response['resources'].size).to eq(2) + expect(parsed_response['resources'].pluck('guid')).to contain_exactly('route-with-service-instance-one', 'route-with-service-instance-two') + end + end + end + + describe 'labels' do + let!(:domain1) { VCAP::CloudController::PrivateDomain.make(name: 'dom1.com', owning_organization: org) } + let!(:route1) { VCAP::CloudController::Route.make(space: space, domain: domain1, host: 'hall', path: '/oates', guid: 'guid-1') } + let!(:route1_label) { VCAP::CloudController::RouteLabelModel.make(resource_guid: route1.guid, key_name: 'animal', value: 'dog') } + + let!(:domain2) { VCAP::CloudController::PrivateDomain.make(name: 'dom2.com', owning_organization: org) } + let!(:route2) { VCAP::CloudController::Route.make(space: space, domain: domain2, guid: 'guid-2') } + let!(:route2_label) { VCAP::CloudController::RouteLabelModel.make(resource_guid: route2.guid, key_name: 'animal', value: 'cow') } + let!(:route2__exclusive_label) { VCAP::CloudController::RouteLabelModel.make(resource_guid: route2.guid, key_name: 'santa', value: 'claus') } + + describe 'label_selectors' do + it 'returns a 200 and the filtered routes for "in" label selector' do + get '/v3/routes?label_selector=animal in (dog)', nil, admin_header + + expect(last_response).to have_status_code(200), last_response.body + parsed_response = Oj.load(last_response.body) + + expected_pagination = { + 'total_results' => 1, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal+in+%28dog%29&page=1&per_page=50" }, + 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal+in+%28dog%29&page=1&per_page=50" }, + 'next' => nil, + 'previous' => nil + } + + expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route1.guid) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + + it 'returns a 200 and the filtered routes for "in" label selector with space guids' do + get "/v3/routes?label_selector=animal in (dog)&space_guids=#{space.guid}", nil, admin_header + + expect(last_response).to have_status_code(200) + parsed_response = Oj.load(last_response.body) + + expected_pagination = { + 'total_results' => 1, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal+in+%28dog%29&page=1&per_page=50&space_guids=#{space.guid}" }, + 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal+in+%28dog%29&page=1&per_page=50&space_guids=#{space.guid}" }, + 'next' => nil, + 'previous' => nil + } + + expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route1.guid) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + + it 'returns a 200 and the filtered routes for "in" label selector with org filters' do + get "/v3/routes?label_selector=animal in (dog)&organization_guids=#{org.guid}", nil, admin_header + + expect(last_response).to have_status_code(200), last_response.body + parsed_response = Oj.load(last_response.body) + + expected_pagination = { + 'total_results' => 1, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal+in+%28dog%29&organization_guids=#{org.guid}&page=1&per_page=50" }, + 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal+in+%28dog%29&organization_guids=#{org.guid}&page=1&per_page=50" }, + 'next' => nil, + 'previous' => nil + } + + expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route1.guid) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + + it 'returns a 200 and the filtered routes for "in" label selector with domain filters' do + get "/v3/routes?label_selector=animal in (dog)&domain_guids=#{domain1.guid}", nil, admin_header + + expect(last_response).to have_status_code(200), last_response.body + parsed_response = Oj.load(last_response.body) + + expected_pagination = { + 'total_results' => 1, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/routes?domain_guids=#{domain1.guid}&label_selector=animal+in+%28dog%29&page=1&per_page=50" }, + 'last' => { 'href' => "#{link_prefix}/v3/routes?domain_guids=#{domain1.guid}&label_selector=animal+in+%28dog%29&page=1&per_page=50" }, + 'next' => nil, + 'previous' => nil + } + + expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route1.guid) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + + it 'returns a 200 and the filtered routes for "in" label selector with host filters' do + get '/v3/routes?label_selector=animal in (dog)&hosts=hall', nil, admin_header + + expect(last_response).to have_status_code(200), last_response.body + parsed_response = Oj.load(last_response.body) + + expected_pagination = { + 'total_results' => 1, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/routes?hosts=hall&label_selector=animal+in+%28dog%29&page=1&per_page=50" }, + 'last' => { 'href' => "#{link_prefix}/v3/routes?hosts=hall&label_selector=animal+in+%28dog%29&page=1&per_page=50" }, + 'next' => nil, + 'previous' => nil + } + + expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route1.guid) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + + it 'returns a 200 and the filtered routes for "in" label selector with path filters' do + get '/v3/routes?label_selector=animal in (dog)&paths=/oates', nil, admin_header + + expect(last_response).to have_status_code(200) + parsed_response = Oj.load(last_response.body) + + expected_pagination = { + 'total_results' => 1, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal+in+%28dog%29&page=1&paths=%2Foates&per_page=50" }, + 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal+in+%28dog%29&page=1&paths=%2Foates&per_page=50" }, + 'next' => nil, + 'previous' => nil + } + + expect(last_response).to have_status_code(200) + expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route1.guid) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + end + + it 'returns a 200 and the filtered routes for "notin" label selector' do + get '/v3/routes?label_selector=animal notin (dog)', nil, admin_header + + parsed_response = Oj.load(last_response.body) + + expected_pagination = { + 'total_results' => 3, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal+notin+%28dog%29&page=1&per_page=50" }, + 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal+notin+%28dog%29&page=1&per_page=50" }, + 'next' => nil, + 'previous' => nil + } + + expect(last_response).to have_status_code(200) + expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route2.guid, route_in_org.guid, route_in_other_org.guid) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + + it 'returns a 200 and the filtered routes for "=" label selector' do + get '/v3/routes?label_selector=animal=dog', nil, admin_header + + parsed_response = Oj.load(last_response.body) + + expected_pagination = { + 'total_results' => 1, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal%3Ddog&page=1&per_page=50" }, + 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal%3Ddog&page=1&per_page=50" }, + 'next' => nil, + 'previous' => nil + } + + expect(last_response).to have_status_code(200) + expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route1.guid) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + + it 'returns a 200 and the filtered domains for "==" label selector' do + get '/v3/routes?label_selector=animal==dog', nil, admin_header + + parsed_response = Oj.load(last_response.body) + + expected_pagination = { + 'total_results' => 1, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal%3D%3Ddog&page=1&per_page=50" }, + 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal%3D%3Ddog&page=1&per_page=50" }, + 'next' => nil, + 'previous' => nil + } + + expect(last_response).to have_status_code(200) + expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route1.guid) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + + it 'returns a 200 and the filtered routes for "!=" label selector' do + get '/v3/routes?label_selector=animal!=dog', nil, admin_header + + parsed_response = Oj.load(last_response.body) + + expected_pagination = { + 'total_results' => 3, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal%21%3Ddog&page=1&per_page=50" }, + 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal%21%3Ddog&page=1&per_page=50" }, + 'next' => nil, + 'previous' => nil + } + + expect(last_response).to have_status_code(200) + expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route2.guid, route_in_org.guid, route_in_other_org.guid) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + + it 'returns a 200 and the filtered routes for "=" label selector' do + get '/v3/routes?label_selector=animal=cow,santa=claus', nil, admin_header + + parsed_response = Oj.load(last_response.body) + + expected_pagination = { + 'total_results' => 1, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal%3Dcow%2Csanta%3Dclaus&page=1&per_page=50" }, + 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal%3Dcow%2Csanta%3Dclaus&page=1&per_page=50" }, + 'next' => nil, + 'previous' => nil + } + + expect(last_response).to have_status_code(200) + expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route2.guid) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + + it 'returns a 200 and the filtered routes for existence label selector' do + get '/v3/routes?label_selector=santa', nil, admin_header + + parsed_response = Oj.load(last_response.body) + + expected_pagination = { + 'total_results' => 1, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=santa&page=1&per_page=50" }, + 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=santa&page=1&per_page=50" }, + 'next' => nil, + 'previous' => nil + } + + expect(last_response).to have_status_code(200) + expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route2.guid) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + + it 'returns a 200 and the filtered routes for non-existence label selector' do + get '/v3/routes?label_selector=!santa', nil, admin_header + + parsed_response = Oj.load(last_response.body) + + expected_pagination = { + 'total_results' => 3, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=%21santa&page=1&per_page=50" }, + 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=%21santa&page=1&per_page=50" }, + 'next' => nil, + 'previous' => nil + } + + expect(last_response).to have_status_code(200) + expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route1.guid, route_in_org.guid, route_in_other_org.guid) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + end + + describe 'eager loading' do + it 'eager loads associated resources that the presenter specifies' do + expect(VCAP::CloudController::RouteFetcher).to receive(:fetch).with( + anything, + hash_including(eager_loaded_associations: %i[domain space route_mappings labels annotations]) + ).and_call_original + + get '/v3/routes', nil, admin_header + expect(last_response).to have_status_code(200) + end + end + + context 'when the request is invalid' do + it 'returns 400 with a meaningful error' do + get '/v3/routes?page=potato', nil, admin_header + expect(last_response).to have_status_code(400) + expect(last_response).to have_error_message('The query parameter is invalid: Page must be a positive integer') + end + end + + context 'when the user is not logged in' do + it 'returns 401 for Unauthenticated requests' do + get '/v3/routes', nil, base_json_headers + expect(last_response).to have_status_code(401) + end + end + end +end diff --git a/spec/request/routes/shared_context.rb b/spec/request/routes/shared_context.rb new file mode 100644 index 00000000000..c634e429f6e --- /dev/null +++ b/spec/request/routes/shared_context.rb @@ -0,0 +1,31 @@ +require 'presenters/v3/space_presenter' +require 'presenters/v3/organization_presenter' + +RSpec.shared_context 'routes request spec' do + let(:user) { VCAP::CloudController::User.make } + let(:admin_header) { admin_headers_for(user) } + let!(:org) { VCAP::CloudController::Organization.make(created_at: 1.hour.ago) } + let!(:space) { VCAP::CloudController::Space.make(name: 'a-space', created_at: 1.hour.ago, organization: org) } + + let(:space_json_generator) do + lambda { |s| + presented_space = VCAP::CloudController::Presenters::V3::SpacePresenter.new(s).to_hash + presented_space[:created_at] = iso8601 + presented_space[:updated_at] = iso8601 + presented_space + } + end + + let(:org_json_generator) do + lambda { |o| + presented_space = VCAP::CloudController::Presenters::V3::OrganizationPresenter.new(o).to_hash + presented_space[:created_at] = iso8601 + presented_space[:updated_at] = iso8601 + presented_space + } + end + + before do + TestConfig.override(kubernetes: {}) + end +end diff --git a/spec/request/routes/sharing_spec.rb b/spec/request/routes/sharing_spec.rb new file mode 100644 index 00000000000..00015c496d5 --- /dev/null +++ b/spec/request/routes/sharing_spec.rb @@ -0,0 +1,918 @@ +require 'spec_helper' +require 'request_spec_shared_examples' +require_relative 'shared_context' + +# Split from spec/request/routes_spec.rb for better test parallelization + +RSpec.describe 'Routes Request' do + include_context 'routes request spec' + + describe 'GET /v3/routes/:guid/relationships/shared_spaces' do + let(:api_call) { ->(user_headers) { get "/v3/routes/#{guid}/relationships/shared_spaces", nil, user_headers } } + let(:target_space_1) { VCAP::CloudController::Space.make(organization: org) } + let(:route) do + route = VCAP::CloudController::Route.make(space:) + route.add_shared_space(target_space_1) + route + end + let(:guid) { route.guid } + let(:space_dev_headers) do + org.add_user(user) + space.add_developer(user) + headers_for(user) + end + let!(:feature_flag) do + VCAP::CloudController::FeatureFlag.make(name: 'route_sharing', enabled: true, error_message: nil) + end + + before do + org.add_user(user) + target_space_1.add_developer(user) + end + + describe 'permissions' do + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS do + let(:expected_codes_and_responses) do + h = Hash.new({ code: 200, response_object: { + data: [ + { + guid: target_space_1.guid + } + ], + links: { + self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{route.guid}/relationships/shared_spaces} } + } + } }.freeze) + + h['org_billing_manager'] = { code: 404 } + h['no_role'] = { code: 404 } + h + end + end + end + + describe 'when route_sharing flag is disabled' do + before do + feature_flag.enabled = false + feature_flag.save + end + + it 'makes users unable to unshare routes' do + api_call.call(space_dev_headers) + + expect(last_response).to have_status_code(403) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => 'Feature Disabled: route_sharing', + 'title' => 'CF-FeatureDisabled', + 'code' => 330_002 + } + ) + ) + end + end + + it 'responds with 404 when the route does not exist' do + get '/v3/routes/some-fake-guid/relationships/shared_spaces', nil, space_dev_headers + + expect(last_response).to have_status_code(404) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => 'Route not found', + 'title' => 'CF-ResourceNotFound' + } + ) + ) + end + end + + describe 'POST /v3/routes/:guid/relationships/shared_spaces' do + let(:api_call) { ->(user_headers) { post "/v3/routes/#{guid}/relationships/shared_spaces", request_body.to_json, user_headers } } + let(:target_space_1) { VCAP::CloudController::Space.make(organization: org) } + let(:target_space_2) { VCAP::CloudController::Space.make(organization: org) } + let(:request_body) do + { + 'data' => [ + { 'guid' => target_space_1.guid }, + { 'guid' => target_space_2.guid } + ] + } + end + let(:route) { VCAP::CloudController::Route.make(space:) } + let(:guid) { route.guid } + let(:space_dev_headers) do + org.add_user(user) + space.add_developer(user) + headers_for(user) + end + let!(:feature_flag) do + VCAP::CloudController::FeatureFlag.make(name: 'route_sharing', enabled: true, error_message: nil) + end + + before do + org.add_user(user) + target_space_1.add_developer(user) + target_space_2.add_developer(user) + end + + describe 'permissions' do + let(:expected_codes_and_responses) do + h = Hash.new({ code: 403, errors: CF_NOT_AUTHORIZED }.freeze) + + h['org_billing_manager'] = { code: 404 } + h['no_role'] = { code: 404 } + + h['admin'] = { code: 200 } + h['space_developer'] = { code: 200 } + h['space_supporter'] = { code: 200 } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + + context 'when organization is suspended' do + let(:expected_codes_and_responses) do + h = super() + %w[space_developer space_supporter].each { |r| h[r] = { code: 403, errors: CF_ORG_SUSPENDED } } + h + end + + before do + org.update(status: VCAP::CloudController::Organization::SUSPENDED) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + + context 'when target organization is suspended' do + let(:target_space_1) do + space = VCAP::CloudController::Space.make + space.organization.add_user(user) + space.organization.update(status: VCAP::CloudController::Organization::SUSPENDED) + space + end + + let(:expected_codes_and_responses) do + h = super() + %w[space_developer space_supporter].each { |r| h[r] = { code: 422 } } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + end + + it 'shares the route to the target space and logs audit event' do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(200) + + event = VCAP::CloudController::Event.last + expect(event.values).to include({ + type: 'audit.route.share', + actor: user.guid, + actee_type: 'route', + actee_name: route.host, + space_guid: space.guid, + organization_guid: space.organization.guid + }) + expect(event.metadata['target_space_guids']).to include(target_space_1.guid, target_space_2.guid) + + route.reload + expect(route.shared_spaces).to include(target_space_1, target_space_2) + end + + it 'reports that the route is now shared' do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(200) + route.reload + expect(route.shared_spaces).to include(target_space_1, target_space_2) + expect(route).to be_shared + end + + it 'reports that the route is not shared when it has not been shared' do + route.reload + expect(route.shared_spaces).to be_empty + expect(route).not_to be_shared + end + + describe 'when route_sharing flag is disabled' do + before do + feature_flag.enabled = false + feature_flag.save + end + + it 'makes users unable to share routes' do + api_call.call(space_dev_headers) + + expect(last_response).to have_status_code(403) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => 'Feature Disabled: route_sharing', + 'title' => 'CF-FeatureDisabled', + 'code' => 330_002 + } + ) + ) + end + end + + it 'responds with 404 when the route does not exist' do + post '/v3/routes/some-fake-guid/relationships/shared_spaces', request_body.to_json, space_dev_headers + + expect(last_response).to have_status_code(404) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => 'Route not found', + 'title' => 'CF-ResourceNotFound' + } + ) + ) + end + + describe 'when the request body is invalid' do + context 'when it is not a valid relationship' do + let(:request_body) do + { + 'data' => { 'guid' => target_space_1.guid } + } + end + + it 'responds with 422' do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(422) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => 'Data must be an array', + 'title' => 'CF-UnprocessableEntity' + } + ) + ) + end + end + + context 'when there are additional keys' do + let(:request_body) do + { + 'data' => [ + { 'guid' => target_space_1.guid } + ], + 'fake-key' => 'foo' + } + end + + it 'responds with 422' do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(422) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => "Unknown field(s): 'fake-key'", + 'title' => 'CF-UnprocessableEntity' + } + ) + ) + end + end + end + + describe 'target space to share to' do + context 'does not exist' do + let(:target_space_guid) { 'fake-target' } + let(:request_body) do + { + 'data' => [ + { 'guid' => target_space_guid } + ] + } + end + + it 'responds with 422' do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(422) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => "Unable to share route #{route.uri} with spaces ['#{target_space_guid}']. " \ + 'Ensure the spaces exist and that you have access to them.', + 'title' => 'CF-UnprocessableEntity' + } + ) + ) + end + end + + context 'user does not have access to one of the target spaces' do + let(:no_access_target_space) { VCAP::CloudController::Space.make(organization: org) } + let(:request_body) do + { + 'data' => [ + { 'guid' => no_access_target_space.guid }, + { 'guid' => target_space_1.guid } + ] + } + end + + it 'responds with 422 and does not share the route' do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(422) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => "Unable to share route #{route.uri} with spaces ['#{no_access_target_space.guid}']. " \ + 'Ensure the spaces exist and that you have access to them.', + 'title' => 'CF-UnprocessableEntity' + } + ) + ) + + route.reload + expect(route).not_to be_shared + end + end + + context 'already owns the route' do + let(:request_body) do + { + 'data' => [ + { 'guid' => space.guid }, + { 'guid' => target_space_1.guid } + ] + } + end + + it 'responds with 422 and does not share the route' do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(422) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => "Unable to share route '#{route.uri}' with space '#{space.guid}'. " \ + 'Routes cannot be shared into the space where they were created.', + 'title' => 'CF-UnprocessableEntity' + } + ) + ) + + route.reload + expect(route).not_to be_shared + end + end + end + + describe 'errors while sharing' do + # isolation segments? + end + end + + describe 'DELETE /v3/routes/:guid/relationships/shared_spaces/:space_guid' do + let(:api_call) { ->(user_headers) { delete "/v3/routes/#{guid}/relationships/shared_spaces/#{unshared_space_guid}", request_body.to_json, user_headers } } + let(:target_space_1) { VCAP::CloudController::Space.make(organization: org) } + let(:target_space_2) { VCAP::CloudController::Space.make(organization: org) } + let(:target_space_3) { VCAP::CloudController::Space.make(organization: org) } + let(:target_space_not_shared_with_route) { VCAP::CloudController::Space.make(organization: org) } + let(:space_to_unshare) { target_space_2 } + let(:unshared_space_guid) { space_to_unshare.guid } + let(:request_body) { {} } + let(:route) do + route = VCAP::CloudController::Route.make(space:) + route.add_shared_space(target_space_1) + route.add_shared_space(target_space_2) + route.add_shared_space(target_space_3) + route + end + let(:guid) { route.guid } + let(:space_dev_headers) do + org.add_user(user) + space.add_developer(user) + headers_for(user) + end + let!(:feature_flag) do + VCAP::CloudController::FeatureFlag.make(name: 'route_sharing', enabled: true, error_message: nil) + end + + before do + org.add_user(user) + target_space_1.add_developer(user) + target_space_2.add_developer(user) + target_space_not_shared_with_route.add_developer(user) + end + + describe 'permissions' do + let(:expected_codes_and_responses) do + h = Hash.new({ code: 403, errors: CF_NOT_AUTHORIZED }.freeze) + + h['org_billing_manager'] = { code: 404 } + h['no_role'] = { code: 404 } + + h['admin'] = { code: 204 } + h['space_developer'] = { code: 204 } + h['space_supporter'] = { code: 204 } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + + context 'when organization is suspended' do + let(:expected_codes_and_responses) do + h = super() + %w[space_developer space_supporter].each { |r| h[r] = { code: 403, errors: CF_ORG_SUSPENDED } } + h + end + + before do + org.update(status: VCAP::CloudController::Organization::SUSPENDED) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + + context 'when target organization is suspended' do + let(:space_to_unshare) do + space = VCAP::CloudController::Space.make + space.organization.add_user(user) + space.add_developer(user) + space.organization.update(status: VCAP::CloudController::Organization::SUSPENDED) + space + end + + let(:expected_codes_and_responses) do + h = super() + %w[space_developer space_supporter].each do |r| + h[r] = { + code: 422, + errors: [{ + detail: "Unable to unshare route '#{route.uri}' from space '#{space_to_unshare.guid}'. The target organization is suspended.", + title: 'CF-UnprocessableEntity', + code: 10_008 + }] + } + end + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + end + + it 'unshares the specified route from the target space and logs audit event' do + expect(route.shared_spaces).to include(target_space_1, space_to_unshare, target_space_3) + + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(204) + + event = VCAP::CloudController::Event.last + expect(event.values).to include({ + type: 'audit.route.unshare', + actor: user.guid, + actee_type: 'route', + actee_name: route.host, + space_guid: space.guid, + organization_guid: space.organization.guid + }) + expect(event.metadata['target_space_guid']).to eq(unshared_space_guid) + + route.reload + expect(route.shared_spaces).to include(target_space_1, target_space_3) + end + + describe 'when route_sharing flag is disabled' do + before do + feature_flag.enabled = false + feature_flag.save + end + + it 'makes users unable to unshare routes' do + api_call.call(space_dev_headers) + + expect(last_response).to have_status_code(403) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => 'Feature Disabled: route_sharing', + 'title' => 'CF-FeatureDisabled', + 'code' => 330_002 + } + ) + ) + end + end + + it 'responds with 204 when the route is not shared with the specified space' do + delete "/v3/routes/#{route.guid}/relationships/shared_spaces/#{target_space_not_shared_with_route.guid}", request_body.to_json, space_dev_headers + + expect(last_response.status).to eq(204) + end + + it "responds with 404 when the route doesn't exist" do + delete "/v3/routes/some-fake-guid/relationships/shared_spaces/#{target_space_1.guid}", request_body.to_json, space_dev_headers + + expect(last_response).to have_status_code(404) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => 'Route not found', + 'title' => 'CF-ResourceNotFound' + } + ) + ) + end + + context 'attempting to unshare from space that owns us' do + let(:space_to_unshare) { space } + + it 'responds with 422 and does not unshare the roue' do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(422) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => "Unable to unshare route '#{route.uri}' from space " \ + "'#{space.guid}'. Routes cannot be removed from the space that owns them.", + 'title' => 'CF-UnprocessableEntity' + } + ) + ) + + route.reload + expect(route.shared_spaces).to contain_exactly(target_space_1, target_space_2, target_space_3) + end + end + + describe 'target space to unshare with' do + context 'does not exist' do + let(:unshared_space_guid) { 'fake-target' } + + it 'responds with 422' do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(422) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => "Unable to unshare route '#{route.uri}' from space '#{unshared_space_guid}'. " \ + 'Ensure the space exists and that you have access to it.', + 'title' => 'CF-UnprocessableEntity' + } + ) + ) + end + end + + context 'user does not have read access to the target space' do + let(:unshared_space_guid) { VCAP::CloudController::Space.make(organization: org).guid } + + it 'responds with 422 and does not share the route' do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(422) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => "Unable to unshare route '#{route.uri}' from space '#{unshared_space_guid}'. " \ + 'Ensure the space exists and that you have access to it.', + 'title' => 'CF-UnprocessableEntity' + } + ) + ) + end + end + + context 'user does not have write access to the target space' do + let(:no_write_access_target_space) { VCAP::CloudController::Space.make(organization: org) } + let(:unshared_space_guid) { no_write_access_target_space.guid } + + before do + no_write_access_target_space.add_auditor(user) + end + + it 'responds with 422 and does not share the route' do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(422) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => "Unable to unshare route '#{route.uri}' from space '#{no_write_access_target_space.guid}'. " \ + "You don't have write permission for the target space.", + 'title' => 'CF-UnprocessableEntity' + } + ) + ) + end + end + end + end + + describe 'PATCH /v3/routes/:guid/relationships/space' do + let(:shared_domain) { VCAP::CloudController::SharedDomain.make } + let(:route) { VCAP::CloudController::Route.make(space: space, domain: shared_domain) } + let(:api_call) { ->(user_headers) { patch "/v3/routes/#{route.guid}/relationships/space", request_body.to_json, user_headers } } + let(:target_space) { VCAP::CloudController::Space.make(organization: org) } + let(:request_body) do + { + data: { 'guid' => target_space.guid } + } + end + let(:space_dev_headers) do + org.add_user(user) + space.add_developer(user) + headers_for(user) + end + let!(:feature_flag) do + VCAP::CloudController::FeatureFlag.make(name: 'route_sharing', enabled: true, error_message: nil) + end + + before do + org.add_user(user) + target_space.add_developer(user) + end + + context 'when the user logged in' do + let(:expected_codes_and_responses) do + h = Hash.new({ code: 403, errors: CF_NOT_AUTHORIZED }.freeze) + h['admin'] = { code: 200 } + h['no_role'] = { code: 404 } + h['org_billing_manager'] = { code: 404 } + h['space_developer'] = { code: 200 } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + + context 'when organization is suspended' do + let(:expected_codes_and_responses) do + h = super() + %w[space_developer].each { |r| h[r] = { code: 403, errors: CF_ORG_SUSPENDED } } + h + end + + before do + org.update(status: VCAP::CloudController::Organization::SUSPENDED) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + + context 'when target organization is suspended' do + let(:suspended_space) { VCAP::CloudController::Space.make } + let(:request_body) do + { + data: { 'guid' => suspended_space.guid } + } + end + + let(:expected_codes_and_responses) do + h = super() + %w[space_developer].each do |r| + h[r] = { + code: 422, + errors: [{ + detail: "Unable to transfer owner of route '#{route.uri}' to space '#{suspended_space.guid}'. The target organization is suspended.", + title: 'CF-UnprocessableEntity', + code: 10_008 + }] + } + end + h + end + + before do + suspended_space.organization.add_user(user) + suspended_space.add_developer(user) + suspended_space.organization.update(status: VCAP::CloudController::Organization::SUSPENDED) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + end + + it 'changes the route owner to the given space and logs an event', isolation: :truncation do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(200) + + event = VCAP::CloudController::Event.last + expect(event.values).to include({ + type: 'audit.route.transfer-owner', + actor: user.guid, + actee_type: 'route', + actee_name: route.host, + space_guid: space.guid, + organization_guid: space.organization.guid + }) + expect(event.metadata['target_space_guid']).to eq(target_space.guid) + + route.reload + expect(route.space).to eq target_space + end + + describe 'when using a private domain' do + let(:private_domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: org) } + let(:route) { VCAP::CloudController::Route.make(space: space, domain: private_domain) } + let(:second_org) { VCAP::CloudController::Organization.make } + let(:another_space) { VCAP::CloudController::Space.make(organization: second_org) } + let(:request_body) do + { + data: { 'guid' => another_space.guid } + } + end + let(:space_dev_headers) do + org.add_user(user) + space.add_developer(user) + second_org.add_user(user) + another_space.add_developer(user) + headers_for(user) + end + + it 'responds with 422' do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(422) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => "Unable to transfer owner of route '#{route.uri}' to space '#{another_space.guid}'. " \ + "Target space does not have access to route's domain", + 'title' => 'CF-UnprocessableEntity' + } + ) + ) + end + end + + describe 'target space to transfer to' do + context 'does not exist' do + let(:target_space_guid) { 'fake-target' } + let(:request_body) do + { + data: { 'guid' => target_space_guid } + } + end + + it 'responds with 422' do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(422) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => "Unable to transfer owner of route '#{route.uri}' to space '#{target_space_guid}'. " \ + 'Ensure the space exists and that you have access to it.', + 'title' => 'CF-UnprocessableEntity' + } + ) + ) + end + end + + context 'user does not have read access to the target space' do + let(:no_access_target_space) { VCAP::CloudController::Space.make(organization: org) } + let(:request_body) do + { + data: { 'guid' => no_access_target_space.guid } + } + end + + it 'responds with 422 and does not share the route' do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(422) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => "Unable to transfer owner of route '#{route.uri}' to space '#{no_access_target_space.guid}'. " \ + 'Ensure the space exists and that you have access to it.', + 'title' => 'CF-UnprocessableEntity' + } + ) + ) + end + end + + context 'user does not have write access to the target space' do + let(:no_write_access_target_space) { VCAP::CloudController::Space.make(organization: org) } + let(:request_body) do + { + data: { 'guid' => no_write_access_target_space.guid } + } + end + + before do + no_write_access_target_space.add_auditor(user) + end + + it 'responds with 422 and does not share the route' do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(422) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => "Unable to transfer owner of route '#{route.uri}' to space '#{no_write_access_target_space.guid}'. " \ + "You don't have write permission for the target space.", + 'title' => 'CF-UnprocessableEntity' + } + ) + ) + end + end + end + + it 'responds with 404 when the route does not exist' do + patch '/v3/routes/some-fake-guid/relationships/space', request_body.to_json, space_dev_headers + + expect(last_response).to have_status_code(404) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => 'Route not found', + 'title' => 'CF-ResourceNotFound' + } + ) + ) + end + + describe 'when the request body is invalid' do + context 'when there are additional keys' do + let(:request_body) do + { + data: { 'guid' => target_space.guid }, + 'fake-key' => 'foo' + } + end + + it 'responds with 422' do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(422) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => "Unknown field(s): 'fake-key'", + 'title' => 'CF-UnprocessableEntity' + } + ) + ) + end + end + + context 'when data is not a hash' do + let(:request_body) do + { + data: [{ 'guid' => target_space.guid }] + } + end + + it 'responds with 422' do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(422) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => 'Data must be an object', + 'title' => 'CF-UnprocessableEntity' + } + ) + ) + end + end + end + + describe 'when route_sharing flag is disabled' do + before do + feature_flag.enabled = false + feature_flag.save + end + + it 'makes users unable to transfer-owner' do + api_call.call(space_dev_headers) + + expect(last_response).to have_status_code(403) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => 'Feature Disabled: route_sharing', + 'title' => 'CF-FeatureDisabled', + 'code' => 330_002 + } + ) + ) + end + end + end +end diff --git a/spec/request/routes/show_spec.rb b/spec/request/routes/show_spec.rb new file mode 100644 index 00000000000..d566de16b32 --- /dev/null +++ b/spec/request/routes/show_spec.rb @@ -0,0 +1,172 @@ +require 'spec_helper' +require 'request_spec_shared_examples' +require_relative 'shared_context' + +# Split from spec/request/routes_spec.rb for better test parallelization + +RSpec.describe 'Routes Request' do + include_context 'routes request spec' + + describe 'GET /v3/routes/:guid' do + let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } + let(:route) { VCAP::CloudController::Route.make(space:, domain:) } + let(:api_call) { ->(user_headers) { get "/v3/routes/#{route.guid}", nil, user_headers } } + let(:route_json) do + { + guid: route.guid, + protocol: route.domain.protocols[0], + host: route.host, + path: route.path, + port: nil, + url: "#{route.host}.#{route.domain.name}#{route.path}", + created_at: iso8601, + updated_at: iso8601, + destinations: [], + relationships: { + space: { + data: { guid: route.space.guid } + }, + domain: { + data: { guid: route.domain.guid } + } + }, + metadata: { + labels: {}, + annotations: {} + }, + options: {}, + links: { + self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, + space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{route.space.guid}} }, + destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, + domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{route.domain.guid}} } + } + } + end + + context 'when the user is a member in the routes org' do + let(:expected_codes_and_responses) do + h = Hash.new( + { code: 200, + response_object: route_json }.freeze + ) + + h['org_billing_manager'] = { code: 404 } + h['no_role'] = { code: 404 } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + + describe 'when the user is not logged in' do + it 'returns 401 for Unauthenticated requests' do + get "/v3/routes/#{route.guid}", nil, base_json_headers + expect(last_response).to have_status_code(401) + end + end + + describe 'includes' do + context 'when including domains' do + let(:domain_json) do + { + guid: domain.guid, + created_at: iso8601, + updated_at: iso8601, + name: domain.name, + internal: false, + router_group: nil, + supported_protocols: ['http'], + metadata: { + labels: {}, + annotations: {} + }, + relationships: { + organization: { + data: { guid: domain.owning_organization.guid } + }, + shared_organizations: { + data: [] + } + }, + links: { + self: { href: "#{link_prefix}/v3/domains/#{domain.guid}" }, + organization: { href: %r{#{Regexp.escape(link_prefix)}/v3/organizations/#{domain.owning_organization.guid}} }, + route_reservations: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}/route_reservations} }, + shared_organizations: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}/relationships/shared_organizations} } + } + } + end + let(:route_json) do + { + guid: route.guid, + protocol: route.domain.protocols[0], + host: route.host, + path: route.path, + port: nil, + url: "#{route.host}.#{route.domain.name}#{route.path}", + created_at: iso8601, + updated_at: iso8601, + destinations: [], + relationships: { + space: { + data: { guid: route.space.guid } + }, + domain: { + data: { guid: route.domain.guid } + } + }, + metadata: { + labels: {}, + annotations: {} + }, + options: {}, + links: { + self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, + space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{route.space.guid}} }, + destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, + domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{route.domain.guid}} } + }, + included: { domains: [domain_json] } + } + end + + it 'includes the domain for the route' do + get "/v3/routes/#{route.guid}?include=domain", nil, admin_header + expect(last_response).to have_status_code(200), last_response.body + expect(parsed_response).to match_json_response(route_json) + end + end + + context 'when including spaces and orgs' do + it 'includes the unique spaces and organizations for the routes' do + get "/v3/routes/#{route.guid}?include=space,space.organization", nil, admin_header + expect(last_response).to have_status_code(200) + expect(parsed_response['included']).to match_json_response( + 'spaces' => [ + space_json_generator.call(space) + ], + 'organizations' => [ + org_json_generator.call(org) + ] + ) + end + + context 'user is org_auditor' do + let(:user_header) { set_user_with_header_as_role(user: user, role: 'org_auditor', org: org) } + + it 'includes the unique organizations for the routes, but no spaces' do + get "/v3/routes/#{route.guid}?include=space,space.organization", nil, user_header + expect(last_response).to have_status_code(200) + expect(parsed_response['included']).to match_json_response( + 'spaces' => [], + 'organizations' => [ + org_json_generator.call(org) + ] + ) + end + end + end + end + end +end diff --git a/spec/request/routes/update_and_delete_spec.rb b/spec/request/routes/update_and_delete_spec.rb new file mode 100644 index 00000000000..79c60c5ab68 --- /dev/null +++ b/spec/request/routes/update_and_delete_spec.rb @@ -0,0 +1,278 @@ +require 'spec_helper' +require 'request_spec_shared_examples' +require_relative 'shared_context' + +# Split from spec/request/routes_spec.rb for better test parallelization + +RSpec.describe 'Routes Request' do + include_context 'routes request spec' + + describe 'PATCH /v3/routes/:guid' do + let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } + let(:route) { VCAP::CloudController::Route.make(space: space, domain: domain, host: '') } + let(:api_call) { ->(user_headers) { patch "/v3/routes/#{route.guid}", params.to_json, user_headers } } + let(:params) do + { + metadata: { + labels: { + potato: 'fingerling', + style: 'roasted' + }, + annotations: { + potato: 'russet', + style: 'fried' + } + } + } + end + + let(:route_json) do + { + guid: UUID_REGEX, + protocol: domain.protocols[0], + host: '', + path: '', + port: nil, + url: domain.name, + created_at: iso8601, + updated_at: iso8601, + destinations: [], + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + }, + links: { + self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, + space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}} }, + destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, + domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } + }, + metadata: { + labels: { + potato: 'fingerling', + style: 'roasted' + }, + annotations: { + potato: 'russet', + style: 'fried' + } + }, + options: {} + } + end + + context 'when the user logged in' do + let(:expected_codes_and_responses) do + h = Hash.new({ code: 403, errors: CF_NOT_AUTHORIZED }.freeze) + h['admin'] = { code: 200, response_object: route_json } + h['no_role'] = { code: 404 } + h['org_billing_manager'] = { code: 404 } + h['space_developer'] = { code: 200, response_object: route_json } + h['space_supporter'] = { code: 200, response_object: route_json } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + + context 'when organization is suspended' do + let(:expected_codes_and_responses) do + h = super() + %w[space_developer space_supporter].each { |r| h[r] = { code: 403, errors: CF_ORG_SUSPENDED } } + h + end + + before do + org.update(status: VCAP::CloudController::Organization::SUSPENDED) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + end + + context 'when the user is not a member in the routes org' do + let(:other_space) { VCAP::CloudController::Space.make } + let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: other_space.organization) } + let(:route) { VCAP::CloudController::Route.make(space: other_space, domain: domain, host: '') } + + let(:route_json) do + { + guid: UUID_REGEX, + protocol: domain.protocols[0], + host: '', + path: '', + port: nil, + url: domain.name, + created_at: iso8601, + updated_at: iso8601, + destinations: [], + relationships: { + space: { + data: { guid: other_space.guid } + }, + domain: { + data: { guid: domain.guid } + } + }, + links: { + self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, + space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{other_space.guid}} }, + destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, + domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } + }, + metadata: { + labels: { + potato: 'fingerling', + style: 'roasted' + }, + annotations: { + potato: 'russet', + style: 'fried' + } + }, + options: {} + } + end + let(:expected_codes_and_responses) do + h = Hash.new({ code: 404 }.freeze) + h['admin'] = { + code: 200, + response_object: route_json + } + h['admin_read_only'] = { + code: 403 + } + h['global_auditor'] = { + code: 403 + } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + + context 'when route does not exist' do + it 'returns a 404 with a helpful error message' do + patch "/v3/routes/#{user.guid}", params.to_json, admin_header + + expect(last_response).to have_status_code(404) + expect(last_response).to have_error_message('Route not found') + end + end + + context 'when request input message is invalid' do + let(:params_with_invalid_input) do + { + disallowed_key: 'val' + } + end + + it 'returns a 422' do + patch "/v3/routes/#{route.guid}", params_with_invalid_input.to_json, admin_header + + expect(last_response).to have_status_code(422) + end + end + + context 'when metadata is given with invalid format' do + let(:params_with_invalid_metadata_format) do + { + metadata: { + labels: { + "": 'mashed', + '/potato': '.value.' + } + } + } + end + + it 'returns a 422' do + patch "/v3/routes/#{route.guid}", params_with_invalid_metadata_format.to_json, admin_header + + expect(last_response).to have_status_code(422) + expect(parsed_response['errors'][0]['detail']).to match(/Metadata [\w\s]+ error/) + end + end + + context 'when the user is not logged in' do + it 'returns 401 for Unauthenticated requests' do + patch "/v3/routes/#{route.guid}", nil, base_json_headers + expect(last_response).to have_status_code(401) + end + end + end + + describe 'DELETE /v3/routes/:guid' do + let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } + let(:route) { VCAP::CloudController::Route.make(space:, domain:) } + let(:api_call) { ->(user_headers) { delete "/v3/routes/#{route.guid}", nil, user_headers } } + let(:db_check) do + lambda do + expect(last_response.headers['Location']).to match(%r{http.+/v3/jobs/[a-fA-F0-9-]+}) + + execute_all_jobs(expected_successes: 1, expected_failures: 0) + get "/v3/routes/#{route.guid}", {}, admin_headers + expect(last_response).to have_status_code(404) + end + end + + context 'deleting metadata' do + it_behaves_like 'resource with metadata' do + let(:resource) { route } + let(:api_call) do + -> { delete "/v3/routes/#{route.guid}", nil, admin_header } + end + end + end + + context 'when the user is a member in the routes org' do + let(:expected_codes_and_responses) do + h = Hash.new({ code: 403, errors: CF_NOT_AUTHORIZED }.freeze) + h['org_billing_manager'] = { code: 404 } + h['no_role'] = { code: 404 } + h['admin'] = { code: 202 } + h['space_developer'] = { code: 202 } + h['space_supporter'] = { code: 202 } + h + end + + it_behaves_like 'permissions for delete endpoint', ALL_PERMISSIONS do + let(:expected_event_hash) do + { + type: 'audit.route.delete-request', + actee: route.guid, + actee_type: 'route', + actee_name: route.host, + metadata: { request: { recursive: true } }.to_json, + space_guid: space.guid, + organization_guid: org.guid + } + end + end + + context 'when organization is suspended' do + let(:expected_codes_and_responses) do + h = super() + %w[space_developer space_supporter].each { |r| h[r] = { code: 403, errors: CF_ORG_SUSPENDED } } + h + end + + before do + org.update(status: VCAP::CloudController::Organization::SUSPENDED) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + end + + describe 'when the user is not logged in' do + it 'returns 401 for Unauthenticated requests' do + delete "/v3/routes/#{route.guid}", nil, base_json_headers + expect(last_response).to have_status_code(401) + end + end + end +end diff --git a/spec/request/routes_spec.rb b/spec/request/routes_spec.rb deleted file mode 100644 index 2c2fe30cd84..00000000000 --- a/spec/request/routes_spec.rb +++ /dev/null @@ -1,3748 +0,0 @@ -require 'spec_helper' -require 'request_spec_shared_examples' -require 'presenters/v3/space_presenter' -require 'presenters/v3/organization_presenter' - -RSpec.describe 'Routes Request' do - let(:user) { VCAP::CloudController::User.make } - let(:admin_header) { admin_headers_for(user) } - let!(:org) { VCAP::CloudController::Organization.make(created_at: 1.hour.ago) } - let!(:space) { VCAP::CloudController::Space.make(name: 'a-space', created_at: 1.hour.ago, organization: org) } - - let(:space_json_generator) do - lambda { |s| - presented_space = VCAP::CloudController::Presenters::V3::SpacePresenter.new(s).to_hash - presented_space[:created_at] = iso8601 - presented_space[:updated_at] = iso8601 - presented_space - } - end - - let(:org_json_generator) do - lambda { |o| - presented_space = VCAP::CloudController::Presenters::V3::OrganizationPresenter.new(o).to_hash - presented_space[:created_at] = iso8601 - presented_space[:updated_at] = iso8601 - presented_space - } - end - - before do - TestConfig.override(kubernetes: {}) - end - - describe 'GET /v3/routes' do - let(:other_space) { VCAP::CloudController::Space.make(name: 'b-space') } - let(:app_model) { VCAP::CloudController::AppModel.make(space:) } - let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } - let!(:route_in_org) do - VCAP::CloudController::Route.make(space: space, domain: domain, host: 'host-1', path: '/path1', guid: 'route-in-org-guid') - end - let!(:route_in_other_org) do - VCAP::CloudController::Route.make(space: other_space, host: 'host-2', path: '/path2', guid: 'route-in-other-org-guid') - end - let!(:route_in_org_dest_web) { VCAP::CloudController::RouteMappingModel.make(app: app_model, route: route_in_org, process_type: 'web') } - let!(:route_in_org_dest_worker) { VCAP::CloudController::RouteMappingModel.make(app: app_model, route: route_in_org, process_type: 'worker') } - let(:api_call) { ->(user_headers) { get '/v3/routes', nil, user_headers } } - let(:route_in_org_json) do - { - guid: route_in_org.guid, - protocol: route_in_org.domain.protocols[0], - host: route_in_org.host, - path: route_in_org.path, - port: nil, - url: "#{route_in_org.host}.#{route_in_org.domain.name}#{route_in_org.path}", - created_at: iso8601, - updated_at: iso8601, - destinations: contain_exactly({ - guid: route_in_org_dest_web.guid, - app: { - guid: app_model.guid, - process: { - type: route_in_org_dest_web.process_type - } - }, - weight: route_in_org_dest_web.weight, - port: route_in_org_dest_web.presented_port, - protocol: 'http1', - created_at: iso8601, - updated_at: iso8601 - }, { - guid: route_in_org_dest_worker.guid, - app: { - guid: app_model.guid, - process: { - type: route_in_org_dest_worker.process_type - } - }, - weight: route_in_org_dest_worker.weight, - port: route_in_org_dest_worker.presented_port, - protocol: 'http1', - created_at: iso8601, - updated_at: iso8601 - }), - relationships: { - space: { - data: { guid: route_in_org.space.guid } - }, - domain: { - data: { guid: route_in_org.domain.guid } - } - }, - metadata: { - labels: {}, - annotations: {} - }, - options: {}, - links: { - self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{route_in_org.guid}} }, - space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{route_in_org.space.guid}} }, - destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{route_in_org.guid}/destinations} }, - domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{route_in_org.domain.guid}} } - } - } - end - - let(:route_in_other_org_json) do - { - guid: route_in_other_org.guid, - protocol: route_in_other_org.domain.protocols[0], - host: route_in_other_org.host, - path: route_in_other_org.path, - port: nil, - url: "#{route_in_other_org.host}.#{route_in_other_org.domain.name}#{route_in_other_org.path}", - created_at: iso8601, - updated_at: iso8601, - destinations: [], - relationships: { - space: { - data: { guid: route_in_other_org.space.guid } - }, - domain: { - data: { guid: route_in_other_org.domain.guid } - } - }, - metadata: { - labels: {}, - annotations: {} - }, - options: {}, - links: { - self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{route_in_other_org.guid}} }, - space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{route_in_other_org.space.guid}} }, - destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{route_in_other_org.guid}/destinations} }, - domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{route_in_other_org.domain.guid}} } - } - } - end - - it_behaves_like 'list_endpoint_with_common_filters' do - let(:resource_klass) { VCAP::CloudController::Route } - let(:api_call) do - ->(headers, filters) { get "/v3/routes?#{filters}", nil, headers } - end - let(:headers) { admin_headers } - end - - describe 'query list parameters' do - it_behaves_like 'list query endpoint' do - let(:request) { 'v3/routes' } - let(:message) { VCAP::CloudController::RoutesListMessage } - let(:user_header) { admin_header } - - let(:params) do - { - page: '2', - per_page: '10', - order_by: 'updated_at', - space_guids: %w[foo bar], - service_instance_guids: %w[baz qux], - organization_guids: %w[foo bar], - domain_guids: %w[foo bar], - app_guids: %w[foo bar], - guids: %w[foo bar], - paths: %w[foo bar], - hosts: 'foo', - ports: 636, - include: 'domain', - label_selector: 'foo,bar', - created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", - updated_ats: { gt: Time.now.utc.iso8601 } - } - end - end - end - - context 'when the user is a member in the routes org' do - let(:expected_codes_and_responses) do - h = Hash.new( - { code: 200, - response_objects: [route_in_org_json] }.freeze - ) - - h['admin'] = { code: 200, response_objects: [route_in_org_json, route_in_other_org_json] } - h['admin_read_only'] = { code: 200, response_objects: [route_in_org_json, route_in_other_org_json] } - h['global_auditor'] = { code: 200, response_objects: [route_in_org_json, route_in_other_org_json] } - - h['org_billing_manager'] = { code: 200, response_objects: [] } - h['no_role'] = { code: 200, response_objects: [] } - h - end - - it_behaves_like 'permissions for list endpoint', ALL_PERMISSIONS - end - - describe 'includes' do - context 'when including domains' do - let(:domain1) { VCAP::CloudController::SharedDomain.make(name: 'first-domain.example.com') } - let(:domain1_json) do - { - guid: domain1.guid, - created_at: iso8601, - updated_at: iso8601, - name: domain1.name, - internal: false, - router_group: nil, - supported_protocols: ['http'], - metadata: { - labels: {}, - annotations: {} - }, - relationships: { - organization: { - data: nil - }, - shared_organizations: { - data: [] - } - }, - links: { - self: { href: "#{link_prefix}/v3/domains/#{domain1.guid}" }, - route_reservations: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain1.guid}/route_reservations} } - } - } - end - - let(:domain2) { VCAP::CloudController::SharedDomain.make(name: 'second-domain.example.com') } - let(:domain2_json) do - { - guid: domain2.guid, - created_at: iso8601, - updated_at: iso8601, - name: domain2.name, - internal: false, - router_group: nil, - supported_protocols: ['http'], - metadata: { - labels: {}, - annotations: {} - }, - relationships: { - organization: { - data: nil - }, - shared_organizations: { - data: [] - } - }, - links: { - self: { href: "#{link_prefix}/v3/domains/#{domain1.guid}" }, - route_reservations: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain1.guid}/route_reservations} } - } - } - end - - let(:domain2) { VCAP::CloudController::SharedDomain.make(name: 'second-domain.example.com') } - let(:domain2_json) do - { - guid: domain2.guid, - created_at: iso8601, - updated_at: iso8601, - name: domain2.name, - internal: false, - router_group: nil, - supported_protocols: ['http'], - metadata: { - labels: {}, - annotations: {} - }, - relationships: { - organization: { - data: nil - }, - shared_organizations: { - data: [] - } - }, - links: { - self: { href: "#{link_prefix}/v3/domains/#{domain2.guid}" }, - route_reservations: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain2.guid}/route_reservations} } - } - } - end - - let!(:route1_domain1) do - VCAP::CloudController::Route.make(space: space, host: 'route1', domain: domain1, path: '/path1', guid: 'route1-guid') - end - let(:route1_domain1_json) do - { - guid: route1_domain1.guid, - protocol: route1_domain1.domain.protocols[0], - created_at: iso8601, - updated_at: iso8601, - host: route1_domain1.host, - path: route1_domain1.path, - port: nil, - url: "#{route1_domain1.host}.#{domain1.name}#{route1_domain1.path}", - destinations: [], - metadata: { - labels: {}, - annotations: {} - }, - relationships: { - space: { - data: { - guid: space.guid - } - }, - domain: { - data: { - guid: domain1.guid - } - } - }, - options: {}, - links: { - self: { href: "http://api2.vcap.me/v3/routes/#{route1_domain1.guid}" }, - space: { href: "http://api2.vcap.me/v3/spaces/#{space.guid}" }, - destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{route1_domain1.guid}/destinations} }, - domain: { href: "http://api2.vcap.me/v3/domains/#{domain1.guid}" } - } - } - end - - let!(:route_in_org) do - VCAP::CloudController::Route.make(space: space, domain: domain1, host: 'host-1', path: '/path1', guid: 'route-in-org-guid') - end - let!(:route_in_other_org) do - VCAP::CloudController::Route.make(space: other_space, domain: domain2, host: 'host-2', path: '/path2', guid: 'route-in-other-org-guid') - end - - it 'includes the unique domains for the routes' do - get '/v3/routes?include=domain', nil, admin_header - expect(last_response).to have_status_code(200) - expect({ - resources: parsed_response['resources'], - included: parsed_response['included'] - }).to match_json_response({ - resources: [route_in_org_json, route_in_other_org_json, route1_domain1_json], - included: { 'domains' => [domain1_json, domain2_json] } - }) - end - end - - context 'when including spaces and orgs' do - it 'includes the unique spaces and organizations for the routes' do - get '/v3/routes?include=space,space.organization', nil, admin_header - expect(last_response).to have_status_code(200) - expect({ - resources: parsed_response['resources'], - included: parsed_response['included'] - }).to match_json_response({ - resources: [route_in_org_json, route_in_other_org_json], - included: { - 'spaces' => [ - space_json_generator.call(space), - space_json_generator.call(other_space) - ], - 'organizations' => [ - org_json_generator.call(org), - org_json_generator.call(other_space.organization) - ] - } - }) - end - end - - context 'when including spaces' do - it 'eagerly loads spaces to efficiently access space_guid' do - expect(VCAP::CloudController::IncludeSpaceDecorator).to receive(:decorate) do |_, resources| - expect(resources).not_to be_empty - resources.each { |r| expect(r.associations).to include(:space) } - end - - get '/v3/routes?include=space', nil, admin_header - expect(last_response).to have_status_code(200) - end - end - - context 'when including orgs' do - it 'eagerly loads spaces to efficiently access space.organization_id' do - expect(VCAP::CloudController::IncludeOrganizationDecorator).to receive(:decorate) do |_, resources| - expect(resources).not_to be_empty - resources.each { |r| expect(r.associations).to include(:space) } - end - - get '/v3/routes?include=space.organization', nil, admin_header - expect(last_response).to have_status_code(200) - end - end - end - - describe 'filters' do - let!(:route_without_host_and_with_path) do - VCAP::CloudController::Route.make(space: space, host: '', domain: domain, path: '/path1', guid: 'route-without-host') - end - let!(:route_without_host_and_with_path2) do - VCAP::CloudController::Route.make(space: space, host: '', domain: domain, path: '/path2', guid: 'route-without-host2') - end - let(:route_without_host_and_with_path_json) do - { - guid: 'route-without-host', - protocol: domain.protocols[0], - created_at: iso8601, - updated_at: iso8601, - destinations: [], - host: '', - path: '/path1', - port: nil, - url: "#{domain.name}/path1", - metadata: { - labels: {}, - annotations: {} - }, - relationships: { - space: { - data: { - guid: space.guid - } - }, - domain: { - data: { - guid: domain.guid - } - } - }, - options: {}, - links: { - self: { href: 'http://api2.vcap.me/v3/routes/route-without-host' }, - space: { href: "http://api2.vcap.me/v3/spaces/#{space.guid}" }, - destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/route-without-host/destinations} }, - domain: { href: "http://api2.vcap.me/v3/domains/#{domain.guid}" } - } - } - end - let(:route_without_host_and_with_path2_json) do - { - guid: 'route-without-host2', - protocol: domain.protocols[0], - created_at: iso8601, - updated_at: iso8601, - destinations: [], - host: '', - path: '/path2', - port: nil, - url: "#{domain.name}/path2", - metadata: { - labels: {}, - annotations: {} - }, - relationships: { - space: { - data: { - guid: space.guid - } - }, - domain: { - data: { - guid: domain.guid - } - } - }, - options: {}, - links: { - self: { href: 'http://api2.vcap.me/v3/routes/route-without-host2' }, - space: { href: "http://api2.vcap.me/v3/spaces/#{space.guid}" }, - destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/route-without-host2/destinations} }, - domain: { href: "http://api2.vcap.me/v3/domains/#{domain.guid}" } - } - } - end - let!(:route_without_path_and_with_host) do - VCAP::CloudController::Route.make(space: space, host: 'host-1', domain: domain, path: '', guid: 'route-without-path') - end - let(:route_without_path_and_with_host_json) do - { - guid: 'route-without-path', - protocol: domain.protocols[0], - created_at: iso8601, - updated_at: iso8601, - destinations: [], - host: 'host-1', - path: '', - port: nil, - url: "host-1.#{domain.name}", - metadata: { - labels: {}, - annotations: {} - }, - relationships: { - space: { - data: { - guid: space.guid - } - }, - domain: { - data: { - guid: domain.guid - } - } - }, - options: {}, - links: { - self: { href: 'http://api2.vcap.me/v3/routes/route-without-path' }, - space: { href: "http://api2.vcap.me/v3/spaces/#{space.guid}" }, - destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/route-without-path/destinations} }, - domain: { href: "http://api2.vcap.me/v3/domains/#{domain.guid}" } - } - } - end - - context 'hosts filter' do - it 'returns routes filtered by host' do - get '/v3/routes?hosts=host-1', nil, admin_header - expect(last_response).to have_status_code(200) - expect({ - resources: parsed_response['resources'] - }).to match_json_response({ - resources: [route_in_org_json, route_without_path_and_with_host_json] - }) - end - - it 'returns route with no host if one exists when filtering by empty host' do - get '/v3/routes?hosts=', nil, admin_header - expect(last_response).to have_status_code(200) - expect({ - resources: parsed_response['resources'] - }).to match_json_response({ - resources: [route_without_host_and_with_path_json, route_without_host_and_with_path2_json] - }) - end - end - - context 'paths filter' do - it 'returns routes filtered by path' do - get '/v3/routes?paths=%2Fpath1', nil, admin_header - expect(last_response).to have_status_code(200) - expect({ - resources: parsed_response['resources'] - }).to match_json_response({ - resources: [route_in_org_json, route_without_host_and_with_path_json] - }) - end - - it 'returns route with no path when filtering by empty path' do - get '/v3/routes?paths=', nil, admin_header - expect(last_response).to have_status_code(200) - expect({ - resources: parsed_response['resources'] - }).to match_json_response({ - resources: [route_without_path_and_with_host_json] - }) - end - end - - context 'hosts and paths filter' do - it 'returns routes with no host and the provided path when host is empty' do - get '/v3/routes?paths=%2Fpath1&hosts=', nil, admin_header - expect(last_response).to have_status_code(200) - expect({ - resources: parsed_response['resources'] - }).to match_json_response({ - resources: [route_without_host_and_with_path_json] - }) - end - end - - context 'organization_guids filter' do - it 'returns routes filtered by organization_guid' do - get "/v3/routes?organization_guids=#{other_space.organization.guid}", nil, admin_header - expect(last_response).to have_status_code(200) - expect({ - resources: parsed_response['resources'] - }).to match_json_response({ - resources: [route_in_other_org_json] - }) - end - end - - context 'space_guids filter' do - it 'returns routes filtered by space_guid' do - get "/v3/routes?space_guids=#{other_space.guid}", nil, admin_header - expect(last_response).to have_status_code(200) - expect({ - resources: parsed_response['resources'] - }).to match_json_response({ - resources: [route_in_other_org_json] - }) - end - end - - context 'domain_guids filter' do - it 'returns routes filtered by domain_guid' do - get "/v3/routes?domain_guids=#{route_in_other_org.domain.guid}", nil, admin_header - expect(last_response).to have_status_code(200) - expect({ - resources: parsed_response['resources'] - }).to match_json_response({ - resources: [route_in_other_org_json] - }) - end - end - - context 'app_guids filter' do - it 'returns routes filtered by app_guid' do - get "/v3/routes?app_guids=#{app_model.guid}", nil, admin_header - expect(last_response).to have_status_code(200) - expect(parsed_response['resources'].size).to eq(1) - expect(parsed_response['resources'].first['destinations'].size).to eq(2) - expect( - parsed_response['resources'].first['destinations'].map { |destination| destination['app']['guid'] }.uniq - ).to eq([app_model.guid]) - end - end - - context 'ports filter' do - # Don't even think of converting the following hash to symbols ('type' => 'tcp' NOT type: 'tcp'), and you need to set the GUID - let(:router_group) { VCAP::CloudController::RoutingApi::RouterGroup.new({ 'type' => 'tcp', 'reservable_ports' => '7777,8888,9999', 'guid' => 'some-guid' }) } - let(:routing_api_client) { instance_double(VCAP::CloudController::RoutingApi::Client) } - - before do - allow_any_instance_of(CloudController::DependencyLocator).to receive(:routing_api_client).and_return(routing_api_client) - allow(routing_api_client).to receive_messages(enabled?: true, router_group: router_group) - end - - context 'when there are multiple TCP routes with different ports' do - # The following `let`s depend on the above `before do` - let(:domain_tcp) { VCAP::CloudController::SharedDomain.make(router_group_guid: router_group.guid, name: 'my.domain') } - let!(:route_with_ports_0) do - VCAP::CloudController::Route.make(host: '', space: space, domain: domain_tcp, guid: 'route-with-port-0', port: 7777) - end - let!(:route_with_ports_1) do - VCAP::CloudController::Route.make(host: '', space: space, domain: domain_tcp, guid: 'route-with-port-1', port: 8888) - end - let!(:route_with_ports_2) do - VCAP::CloudController::Route.make(host: '', space: space, domain: domain_tcp, guid: 'route-with-port-2', port: 9999) - end - - it 'returns routes filtered by ports' do - get '/v3/routes?ports=7777,8888', nil, admin_header - expect(last_response).to have_status_code(200) - expect(parsed_response['resources'].size).to eq(2) - expect(parsed_response['resources'].pluck('port')).to contain_exactly(route_with_ports_0.port, route_with_ports_1.port) - end - end - end - - context 'service instance guids filter' do - let(:service_instance_one) do - VCAP::CloudController::ManagedServiceInstance.make(:routing, space: space, name: 'si-name-1') - end - let(:service_instance_two) do - VCAP::CloudController::ManagedServiceInstance.make(:routing, space: space, name: 'si-name-2') - end - - let!(:route_with_service_instance_one) do - VCAP::CloudController::Route.make(space: space, host: 'host-with-service-instance-one', domain: domain, path: '/path1', guid: 'route-with-service-instance-one') - end - let!(:route_with_service_instance_two) do - VCAP::CloudController::Route.make(space: space, host: 'host-with-service-instance-two', domain: domain, path: '/path2', guid: 'route-with-service-instance-two') - end - - let!(:route_mapping_one) { VCAP::CloudController::RouteBinding.make(route: route_with_service_instance_one, service_instance: service_instance_one) } - let!(:route_mapping_two) { VCAP::CloudController::RouteBinding.make(route: route_with_service_instance_two, service_instance: service_instance_two) } - - it 'returns routes filtered by service instance guid' do - get "/v3/routes?service_instance_guids=#{service_instance_one.guid},#{service_instance_two.guid}", nil, admin_header - expect(last_response).to have_status_code(200) - expect(parsed_response['resources'].size).to eq(2) - expect(parsed_response['resources'].pluck('guid')).to contain_exactly('route-with-service-instance-one', 'route-with-service-instance-two') - end - end - end - - describe 'labels' do - let!(:domain1) { VCAP::CloudController::PrivateDomain.make(name: 'dom1.com', owning_organization: org) } - let!(:route1) { VCAP::CloudController::Route.make(space: space, domain: domain1, host: 'hall', path: '/oates', guid: 'guid-1') } - let!(:route1_label) { VCAP::CloudController::RouteLabelModel.make(resource_guid: route1.guid, key_name: 'animal', value: 'dog') } - - let!(:domain2) { VCAP::CloudController::PrivateDomain.make(name: 'dom2.com', owning_organization: org) } - let!(:route2) { VCAP::CloudController::Route.make(space: space, domain: domain2, guid: 'guid-2') } - let!(:route2_label) { VCAP::CloudController::RouteLabelModel.make(resource_guid: route2.guid, key_name: 'animal', value: 'cow') } - let!(:route2__exclusive_label) { VCAP::CloudController::RouteLabelModel.make(resource_guid: route2.guid, key_name: 'santa', value: 'claus') } - - describe 'label_selectors' do - it 'returns a 200 and the filtered routes for "in" label selector' do - get '/v3/routes?label_selector=animal in (dog)', nil, admin_header - - expect(last_response).to have_status_code(200), last_response.body - parsed_response = Oj.load(last_response.body) - - expected_pagination = { - 'total_results' => 1, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal+in+%28dog%29&page=1&per_page=50" }, - 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal+in+%28dog%29&page=1&per_page=50" }, - 'next' => nil, - 'previous' => nil - } - - expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route1.guid) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - - it 'returns a 200 and the filtered routes for "in" label selector with space guids' do - get "/v3/routes?label_selector=animal in (dog)&space_guids=#{space.guid}", nil, admin_header - - expect(last_response).to have_status_code(200) - parsed_response = Oj.load(last_response.body) - - expected_pagination = { - 'total_results' => 1, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal+in+%28dog%29&page=1&per_page=50&space_guids=#{space.guid}" }, - 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal+in+%28dog%29&page=1&per_page=50&space_guids=#{space.guid}" }, - 'next' => nil, - 'previous' => nil - } - - expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route1.guid) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - - it 'returns a 200 and the filtered routes for "in" label selector with org filters' do - get "/v3/routes?label_selector=animal in (dog)&organization_guids=#{org.guid}", nil, admin_header - - expect(last_response).to have_status_code(200), last_response.body - parsed_response = Oj.load(last_response.body) - - expected_pagination = { - 'total_results' => 1, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal+in+%28dog%29&organization_guids=#{org.guid}&page=1&per_page=50" }, - 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal+in+%28dog%29&organization_guids=#{org.guid}&page=1&per_page=50" }, - 'next' => nil, - 'previous' => nil - } - - expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route1.guid) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - - it 'returns a 200 and the filtered routes for "in" label selector with domain filters' do - get "/v3/routes?label_selector=animal in (dog)&domain_guids=#{domain1.guid}", nil, admin_header - - expect(last_response).to have_status_code(200), last_response.body - parsed_response = Oj.load(last_response.body) - - expected_pagination = { - 'total_results' => 1, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/routes?domain_guids=#{domain1.guid}&label_selector=animal+in+%28dog%29&page=1&per_page=50" }, - 'last' => { 'href' => "#{link_prefix}/v3/routes?domain_guids=#{domain1.guid}&label_selector=animal+in+%28dog%29&page=1&per_page=50" }, - 'next' => nil, - 'previous' => nil - } - - expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route1.guid) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - - it 'returns a 200 and the filtered routes for "in" label selector with host filters' do - get '/v3/routes?label_selector=animal in (dog)&hosts=hall', nil, admin_header - - expect(last_response).to have_status_code(200), last_response.body - parsed_response = Oj.load(last_response.body) - - expected_pagination = { - 'total_results' => 1, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/routes?hosts=hall&label_selector=animal+in+%28dog%29&page=1&per_page=50" }, - 'last' => { 'href' => "#{link_prefix}/v3/routes?hosts=hall&label_selector=animal+in+%28dog%29&page=1&per_page=50" }, - 'next' => nil, - 'previous' => nil - } - - expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route1.guid) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - - it 'returns a 200 and the filtered routes for "in" label selector with path filters' do - get '/v3/routes?label_selector=animal in (dog)&paths=/oates', nil, admin_header - - expect(last_response).to have_status_code(200) - parsed_response = Oj.load(last_response.body) - - expected_pagination = { - 'total_results' => 1, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal+in+%28dog%29&page=1&paths=%2Foates&per_page=50" }, - 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal+in+%28dog%29&page=1&paths=%2Foates&per_page=50" }, - 'next' => nil, - 'previous' => nil - } - - expect(last_response).to have_status_code(200) - expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route1.guid) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - end - - it 'returns a 200 and the filtered routes for "notin" label selector' do - get '/v3/routes?label_selector=animal notin (dog)', nil, admin_header - - parsed_response = Oj.load(last_response.body) - - expected_pagination = { - 'total_results' => 3, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal+notin+%28dog%29&page=1&per_page=50" }, - 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal+notin+%28dog%29&page=1&per_page=50" }, - 'next' => nil, - 'previous' => nil - } - - expect(last_response).to have_status_code(200) - expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route2.guid, route_in_org.guid, route_in_other_org.guid) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - - it 'returns a 200 and the filtered routes for "=" label selector' do - get '/v3/routes?label_selector=animal=dog', nil, admin_header - - parsed_response = Oj.load(last_response.body) - - expected_pagination = { - 'total_results' => 1, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal%3Ddog&page=1&per_page=50" }, - 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal%3Ddog&page=1&per_page=50" }, - 'next' => nil, - 'previous' => nil - } - - expect(last_response).to have_status_code(200) - expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route1.guid) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - - it 'returns a 200 and the filtered domains for "==" label selector' do - get '/v3/routes?label_selector=animal==dog', nil, admin_header - - parsed_response = Oj.load(last_response.body) - - expected_pagination = { - 'total_results' => 1, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal%3D%3Ddog&page=1&per_page=50" }, - 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal%3D%3Ddog&page=1&per_page=50" }, - 'next' => nil, - 'previous' => nil - } - - expect(last_response).to have_status_code(200) - expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route1.guid) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - - it 'returns a 200 and the filtered routes for "!=" label selector' do - get '/v3/routes?label_selector=animal!=dog', nil, admin_header - - parsed_response = Oj.load(last_response.body) - - expected_pagination = { - 'total_results' => 3, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal%21%3Ddog&page=1&per_page=50" }, - 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal%21%3Ddog&page=1&per_page=50" }, - 'next' => nil, - 'previous' => nil - } - - expect(last_response).to have_status_code(200) - expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route2.guid, route_in_org.guid, route_in_other_org.guid) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - - it 'returns a 200 and the filtered routes for "=" label selector' do - get '/v3/routes?label_selector=animal=cow,santa=claus', nil, admin_header - - parsed_response = Oj.load(last_response.body) - - expected_pagination = { - 'total_results' => 1, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal%3Dcow%2Csanta%3Dclaus&page=1&per_page=50" }, - 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal%3Dcow%2Csanta%3Dclaus&page=1&per_page=50" }, - 'next' => nil, - 'previous' => nil - } - - expect(last_response).to have_status_code(200) - expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route2.guid) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - - it 'returns a 200 and the filtered routes for existence label selector' do - get '/v3/routes?label_selector=santa', nil, admin_header - - parsed_response = Oj.load(last_response.body) - - expected_pagination = { - 'total_results' => 1, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=santa&page=1&per_page=50" }, - 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=santa&page=1&per_page=50" }, - 'next' => nil, - 'previous' => nil - } - - expect(last_response).to have_status_code(200) - expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route2.guid) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - - it 'returns a 200 and the filtered routes for non-existence label selector' do - get '/v3/routes?label_selector=!santa', nil, admin_header - - parsed_response = Oj.load(last_response.body) - - expected_pagination = { - 'total_results' => 3, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=%21santa&page=1&per_page=50" }, - 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=%21santa&page=1&per_page=50" }, - 'next' => nil, - 'previous' => nil - } - - expect(last_response).to have_status_code(200) - expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route1.guid, route_in_org.guid, route_in_other_org.guid) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - end - - describe 'eager loading' do - it 'eager loads associated resources that the presenter specifies' do - expect(VCAP::CloudController::RouteFetcher).to receive(:fetch).with( - anything, - hash_including(eager_loaded_associations: %i[domain space route_mappings labels annotations]) - ).and_call_original - - get '/v3/routes', nil, admin_header - expect(last_response).to have_status_code(200) - end - end - - context 'when the request is invalid' do - it 'returns 400 with a meaningful error' do - get '/v3/routes?page=potato', nil, admin_header - expect(last_response).to have_status_code(400) - expect(last_response).to have_error_message('The query parameter is invalid: Page must be a positive integer') - end - end - - context 'when the user is not logged in' do - it 'returns 401 for Unauthenticated requests' do - get '/v3/routes', nil, base_json_headers - expect(last_response).to have_status_code(401) - end - end - end - - describe 'GET /v3/routes/:guid' do - let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } - let(:route) { VCAP::CloudController::Route.make(space:, domain:) } - let(:api_call) { ->(user_headers) { get "/v3/routes/#{route.guid}", nil, user_headers } } - let(:route_json) do - { - guid: route.guid, - protocol: route.domain.protocols[0], - host: route.host, - path: route.path, - port: nil, - url: "#{route.host}.#{route.domain.name}#{route.path}", - created_at: iso8601, - updated_at: iso8601, - destinations: [], - relationships: { - space: { - data: { guid: route.space.guid } - }, - domain: { - data: { guid: route.domain.guid } - } - }, - metadata: { - labels: {}, - annotations: {} - }, - options: {}, - links: { - self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, - space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{route.space.guid}} }, - destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, - domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{route.domain.guid}} } - } - } - end - - context 'when the user is a member in the routes org' do - let(:expected_codes_and_responses) do - h = Hash.new( - { code: 200, - response_object: route_json }.freeze - ) - - h['org_billing_manager'] = { code: 404 } - h['no_role'] = { code: 404 } - h - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - - describe 'when the user is not logged in' do - it 'returns 401 for Unauthenticated requests' do - get "/v3/routes/#{route.guid}", nil, base_json_headers - expect(last_response).to have_status_code(401) - end - end - - describe 'includes' do - context 'when including domains' do - let(:domain_json) do - { - guid: domain.guid, - created_at: iso8601, - updated_at: iso8601, - name: domain.name, - internal: false, - router_group: nil, - supported_protocols: ['http'], - metadata: { - labels: {}, - annotations: {} - }, - relationships: { - organization: { - data: { guid: domain.owning_organization.guid } - }, - shared_organizations: { - data: [] - } - }, - links: { - self: { href: "#{link_prefix}/v3/domains/#{domain.guid}" }, - organization: { href: %r{#{Regexp.escape(link_prefix)}/v3/organizations/#{domain.owning_organization.guid}} }, - route_reservations: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}/route_reservations} }, - shared_organizations: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}/relationships/shared_organizations} } - } - } - end - let(:route_json) do - { - guid: route.guid, - protocol: route.domain.protocols[0], - host: route.host, - path: route.path, - port: nil, - url: "#{route.host}.#{route.domain.name}#{route.path}", - created_at: iso8601, - updated_at: iso8601, - destinations: [], - relationships: { - space: { - data: { guid: route.space.guid } - }, - domain: { - data: { guid: route.domain.guid } - } - }, - metadata: { - labels: {}, - annotations: {} - }, - options: {}, - links: { - self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, - space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{route.space.guid}} }, - destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, - domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{route.domain.guid}} } - }, - included: { domains: [domain_json] } - } - end - - it 'includes the domain for the route' do - get "/v3/routes/#{route.guid}?include=domain", nil, admin_header - expect(last_response).to have_status_code(200), last_response.body - expect(parsed_response).to match_json_response(route_json) - end - end - - context 'when including spaces and orgs' do - it 'includes the unique spaces and organizations for the routes' do - get "/v3/routes/#{route.guid}?include=space,space.organization", nil, admin_header - expect(last_response).to have_status_code(200) - expect(parsed_response['included']).to match_json_response( - 'spaces' => [ - space_json_generator.call(space) - ], - 'organizations' => [ - org_json_generator.call(org) - ] - ) - end - - context 'user is org_auditor' do - let(:user_header) { set_user_with_header_as_role(user: user, role: 'org_auditor', org: org) } - - it 'includes the unique organizations for the routes, but no spaces' do - get "/v3/routes/#{route.guid}?include=space,space.organization", nil, user_header - expect(last_response).to have_status_code(200) - expect(parsed_response['included']).to match_json_response( - 'spaces' => [], - 'organizations' => [ - org_json_generator.call(org) - ] - ) - end - end - end - end - end - - describe 'POST /v3/routes' do - context 'when creating a route in a tcp domain' do - let(:domain) { VCAP::CloudController::SharedDomain.make(router_group_guid: 'some-router-group', router_group_type: 'tcp') } - - before do - token = { token_type: 'Bearer', access_token: 'my-favourite-access-token' } - stub_request(:post, 'https://uaa.service.cf.internal/oauth/token'). - to_return(status: 200, body: token.to_json, headers: { 'Content-Type' => 'application/json' }) - stub_request(:get, 'http://localhost:3000/routing/v1/router_groups'). - to_return(status: 200, body: '[{"guid":"some-router-group","name":"Robby Router","type":"tcp","reservable_ports":"25555"}]', headers: {}) - end - - context 'and the route has a host' do - let(:params) do - { - host: 'my-host', - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - it 'returns a 422 and a helpful error message' do - post '/v3/routes', params.to_json, admin_header - expect(last_response).to have_status_code(422) - expect(last_response).to have_error_message('Hosts are not supported for TCP routes.') - end - end - - context 'and the route has a path' do - let(:params) do - { - path: '/cgi-bin', - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - it 'returns a 422 and a helpful error message' do - post '/v3/routes', params.to_json, admin_header - expect(last_response).to have_status_code(422) - expect(last_response).to have_error_message('Paths are not supported for TCP routes.') - end - end - end - - context 'when creating a route in a scoped domain' do - let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } - - describe 'when creating a route without a host' do - let(:params) do - { - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - let(:route_json) do - { - guid: UUID_REGEX, - protocol: domain.protocols[0], - host: '', - path: '', - port: nil, - url: domain.name, - created_at: iso8601, - updated_at: iso8601, - destinations: [], - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - }, - links: { - self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, - space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}} }, - destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, - domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } - }, - metadata: { - labels: {}, - annotations: {} - }, - options: {} - } - end - - describe 'valid routes' do - let(:api_call) { ->(user_headers) { post '/v3/routes', params.to_json, user_headers } } - - let(:expected_codes_and_responses) do - h = Hash.new( - { code: 403 }.freeze - ) - h['admin'] = { - code: 201, - response_object: route_json - } - h['space_developer'] = { - code: 201, - response_object: route_json - } - h['space_supporter'] = { - code: 201, - response_object: route_json - } - h - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - end - - describe 'when creating a route with a host' do - let(:params) do - { - host: 'some-host', - path: '/some-path', - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - }, - metadata: { - labels: { potato: 'yam' }, - annotations: { style: 'mashed' } - }, - options: {} - } - end - - let(:route_json) do - { - guid: UUID_REGEX, - protocol: domain.protocols[0], - host: 'some-host', - path: '/some-path', - port: nil, - url: "some-host.#{domain.name}/some-path", - created_at: iso8601, - updated_at: iso8601, - destinations: [], - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - }, - links: { - self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, - space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}} }, - destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, - domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } - }, - metadata: { - labels: { potato: 'yam' }, - annotations: { style: 'mashed' } - }, - options: {} - } - end - - describe 'valid routes' do - it_behaves_like 'permissions for single object endpoint', ['admin'] do - let(:api_call) { ->(user_headers) { post '/v3/routes', params.to_json, user_headers } } - - let(:expected_codes_and_responses) do - h = Hash.new( - { code: 403 }.freeze - ) - h['admin'] = { - code: 201, - response_object: route_json - } - h['space_developer'] = { - code: 201, - response_object: route_json - } - h['space_supporter'] = { - code: 201, - response_object: route_json - } - h - end - - let(:expected_event_hash) do - { - type: 'audit.route.create', - actee: parsed_response['guid'], - actee_type: 'route', - actee_name: 'some-host', - metadata: { request: params }.to_json, - space_guid: space.guid, - organization_guid: org.guid - } - end - end - end - end - - describe 'when creating a route with a wildcard host' do - let(:params) do - { - host: '*', - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - let(:route_json) do - { - guid: UUID_REGEX, - protocol: domain.protocols[0], - host: '*', - path: '', - port: nil, - url: "*.#{domain.name}", - created_at: iso8601, - updated_at: iso8601, - destinations: [], - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - }, - links: { - self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, - space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}} }, - destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, - domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } - }, - metadata: { - labels: {}, - annotations: {} - }, - options: {} - } - end - - describe 'valid routes' do - let(:api_call) { ->(user_headers) { post '/v3/routes', params.to_json, user_headers } } - - let(:expected_codes_and_responses) do - h = Hash.new( - { code: 403 }.freeze - ) - h['admin'] = { - code: 201, - response_object: route_json - } - h['space_developer'] = { - code: 201, - response_object: route_json - } - h['space_supporter'] = { - code: 201, - response_object: route_json - } - h - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - end - end - - context 'when creating a route in an unscoped domain' do - let(:domain) { VCAP::CloudController::SharedDomain.make } - - describe 'when creating a route without a host' do - let(:params) do - { - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - it 'fails with a helpful message' do - post '/v3/routes', params.to_json, admin_header - expect(last_response).to have_status_code(422) - expect(last_response).to have_error_message('Missing host. Routes in shared domains must have a host defined.') - end - end - - describe 'when creating a route with a host' do - let(:params) do - { - host: 'some-host', - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - let(:route_json) do - { - guid: UUID_REGEX, - protocol: domain.protocols[0], - host: 'some-host', - path: '', - port: nil, - url: "some-host.#{domain.name}", - created_at: iso8601, - updated_at: iso8601, - destinations: [], - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - }, - links: { - self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, - space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}} }, - destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, - domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } - }, - metadata: { - labels: {}, - annotations: {} - }, - options: {} - } - end - - describe 'valid routes' do - let(:api_call) { ->(user_headers) { post '/v3/routes', params.to_json, user_headers } } - - let(:expected_codes_and_responses) do - h = Hash.new( - { code: 403 }.freeze - ) - h['admin'] = { - code: 201, - response_object: route_json - } - h['space_developer'] = { - code: 201, - response_object: route_json - } - h['space_supporter'] = { - code: 201, - response_object: route_json - } - h - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - end - - describe 'when creating a route with a wildcard host' do - let(:params) do - { - host: '*', - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - let(:route_json) do - { - guid: UUID_REGEX, - protocol: domain.protocols[0], - host: '*', - path: '', - port: nil, - url: "*.#{domain.name}", - created_at: iso8601, - updated_at: iso8601, - destinations: [], - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - }, - links: { - self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, - space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}} }, - destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, - domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } - }, - metadata: { - labels: {}, - annotations: {} - }, - options: {} - } - end - - describe 'valid routes' do - let(:api_call) { ->(user_headers) { post '/v3/routes', params.to_json, user_headers } } - - let(:expected_codes_and_responses) do - h = Hash.new( - { code: 403 }.freeze - ) - h['admin'] = { - code: 201, - response_object: route_json - } - h['space_developer'] = { - code: 422 - } - h['space_supporter'] = { - code: 422 - } - h - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - end - - describe 'the domain supports tcp routes' do - let(:router_group) { VCAP::CloudController::RoutingApi::RouterGroup.new({ 'type' => 'tcp', 'reservable_ports' => '123' }) } - let(:domain) { VCAP::CloudController::SharedDomain.make(router_group_guid: 'some-router-group', name: 'my.domain') } - let(:routing_api_client) { instance_double(VCAP::CloudController::RoutingApi::Client) } - - before do - TestConfig.override( - kubernetes: { host_url: nil }, - external_domain: 'api2.vcap.me', - external_protocol: 'https' - ) - allow_any_instance_of(CloudController::DependencyLocator).to receive(:routing_api_client).and_return(routing_api_client) - allow(routing_api_client).to receive_messages(enabled?: true, router_group: router_group) - end - - let(:params) do - { - port: 123, - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - let(:api_call) { ->(user_headers) { post '/v3/routes', params.to_json, user_headers } } - - let(:route_json) do - { - guid: UUID_REGEX, - port: 123, - host: '', - path: '', - protocol: 'tcp', - url: "#{domain.name}:123", - created_at: iso8601, - updated_at: iso8601, - destinations: [], - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - }, - links: { - self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, - space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}} }, - destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, - domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } - }, - metadata: { - labels: {}, - annotations: {} - }, - options: {} - } - end - - let(:expected_codes_and_responses) do - h = Hash.new( - { code: 403 }.freeze - ) - h['admin'] = { - code: 201, - response_object: route_json - } - h['space_developer'] = { - code: 201, - response_object: route_json - } - h['space_supporter'] = { - code: 201, - response_object: route_json - } - h - end - - context 'and the user provides a valid port' do - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - - context 'and a route with the domain and port already exist' do - let!(:duplicate_route) { VCAP::CloudController::Route.make(host: '', space: space, domain: domain, port: 123) } - - it 'fails with a helpful error message' do - post '/v3/routes', params.to_json, admin_headers - expect(last_response).to have_status_code(422) - expect(last_response).to have_error_message("Route already exists with port '123' for domain 'my.domain'.") - end - end - - context 'and the port is already in use for the router group' do - let!(:other_domain) { VCAP::CloudController::SharedDomain.make(router_group_guid: 'some-router-group', name: 'my.domain2') } - let!(:route_with_port) { VCAP::CloudController::Route.make(host: '', space: space, domain: other_domain, port: 123) } - - it 'fails with a helpful error message' do - post '/v3/routes', params.to_json, admin_headers - expect(last_response).to have_status_code(422) - expect(last_response).to have_error_message("Port '123' is not available. Try a different port or use a different domain.") - end - end - end - - context 'and the user does not provide a port' do - let(:params) do - { - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - - context 'and randomly selected port is already in use' do - let(:existing_route) { VCAP::CloudController::Route.make(host: '', space: space, domain: domain, port: 123) } - - let(:params) do - { - port: existing_route.port, - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - it 'fails with a helpful error message' do - post '/v3/routes', params.to_json, admin_headers - expect(last_response).to have_status_code(422) - expect(last_response).to have_error_message("Route already exists with port '123' for domain 'my.domain'.") - end - end - end - end - end - - context 'when creating a route in a suspended org' do - before do - org.update(status: VCAP::CloudController::Organization::SUSPENDED) - end - - let(:domain) { VCAP::CloudController::SharedDomain.make } - - let(:params) do - { - host: 'some-host', - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - let(:route_json) do - { - guid: UUID_REGEX, - protocol: domain.protocols[0], - host: 'some-host', - path: '', - port: nil, - url: "some-host.#{domain.name}", - created_at: iso8601, - updated_at: iso8601, - destinations: [], - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - }, - metadata: { - labels: {}, - annotations: {} - }, - links: { - self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, - space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}} }, - destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, - domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } - }, - options: {} - } - end - - describe 'valid routes' do - let(:api_call) { ->(user_headers) { post '/v3/routes', params.to_json, user_headers } } - - let(:expected_codes_and_responses) do - h = Hash.new({ code: 403, errors: CF_NOT_AUTHORIZED }.freeze) - h['admin'] = { - code: 201, - response_object: route_json - } - %w[space_developer space_supporter].each { |r| h[r] = { code: 403, errors: CF_ORG_SUSPENDED } } - h - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - end - - context 'when creating a route in an internal domain' do - let(:domain) { VCAP::CloudController::SharedDomain.make(internal: true) } - - describe 'when creating a route with a wildcard host' do - let(:params) do - { - host: '*', - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - it 'fails with a helpful message' do - post '/v3/routes', params.to_json, admin_header - expect(last_response).to have_status_code(422) - expect(last_response).to have_error_message('Wildcard hosts are not supported for internal domains.') - end - end - - describe 'when creating a route with a path' do - let(:params) do - { - host: 'host', - path: '/apath', - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - it 'fails with a helpful message' do - post '/v3/routes', params.to_json, admin_header - expect(last_response).to have_status_code(422) - expect(last_response).to have_error_message('Paths are not supported for internal domains.') - end - end - - describe 'when creating a route with a host' do - let(:params) do - { - host: 'some-host', - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - let(:route_json) do - { - guid: UUID_REGEX, - protocol: domain.protocols[0], - host: 'some-host', - path: '', - port: nil, - url: "some-host.#{domain.name}", - created_at: iso8601, - updated_at: iso8601, - destinations: [], - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - }, - links: { - self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, - space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}} }, - destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, - domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } - }, - metadata: { - labels: {}, - annotations: {} - }, - options: {} - } - end - - describe 'valid routes' do - let(:api_call) { ->(user_headers) { post '/v3/routes', params.to_json, user_headers } } - - let(:expected_codes_and_responses) do - h = Hash.new( - { code: 403 }.freeze - ) - h['admin'] = { - code: 201, - response_object: route_json - } - h['space_developer'] = { - code: 201, - response_object: route_json - } - h['space_supporter'] = { - code: 201, - response_object: route_json - } - h - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - end - end - - context 'when the domain has an owning org that is different from the space\'s parent org' do - let(:other_org) { VCAP::CloudController::Organization.make } - let(:inaccessible_domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: other_org) } - - let(:params_with_inaccessible_domain) do - { - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: inaccessible_domain.guid } - } - } - } - end - - it 'returns a 422 with a helpful error message' do - post '/v3/routes', params_with_inaccessible_domain.to_json, admin_header - expect(last_response).to have_status_code(422) - expect(last_response).to have_error_message("Invalid domain. Domain '#{inaccessible_domain.name}' is not available in organization '#{org.name}'.") - end - end - - context 'when the host-less route has already been created for this domain' do - let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } - let!(:existing_route) { VCAP::CloudController::Route.make(host: '', space: space, domain: domain) } - - let(:params_for_duplicate_route) do - { - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - it 'returns a 422 with a helpful error message' do - post '/v3/routes', params_for_duplicate_route.to_json, admin_header - expect(last_response).to have_status_code(422) - expect(last_response).to have_error_message("Route already exists for domain '#{domain.name}'.") - end - end - - context 'when there is already a route' do - context 'with the host/domain/path combination' do - let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } - let!(:existing_route) { VCAP::CloudController::Route.make(host: 'my-host', path: '/existing', space: space, domain: domain) } - - let(:params_for_duplicate_route) do - { - host: existing_route.host, - path: existing_route.path, - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - it 'returns a 422 with a helpful error message' do - post '/v3/routes', params_for_duplicate_route.to_json, admin_header - expect(last_response).to have_status_code(422) - expect(last_response).to have_error_message("Route already exists with host '#{existing_route.host}' and path '#{existing_route.path}' for domain '#{domain.name}'.") - end - end - - context 'with the host/domain combination' do - let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } - let!(:existing_route) { VCAP::CloudController::Route.make(host: 'my-host', space: space, domain: domain) } - - let(:params_for_duplicate_route) do - { - host: existing_route.host, - path: existing_route.path, - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - it 'returns a 422 with a helpful error message' do - post '/v3/routes', params_for_duplicate_route.to_json, admin_header - expect(last_response).to have_status_code(422) - expect(last_response).to have_error_message("Route already exists with host '#{existing_route.host}' for domain '#{domain.name}'.") - end - end - end - - context 'when there is already a domain matching the host/domain combination' do - let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } - let!(:existing_domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization, name: "#{params[:host]}.#{domain.name}") } - - let(:params) do - { - host: 'some-host', - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - it 'returns a 422 with a helpful error message' do - post '/v3/routes', params.to_json, admin_header - expect(last_response).to have_status_code(422) - expect(last_response).to have_error_message("Route conflicts with domain '#{existing_domain.name}'.") - end - end - - context 'when using a reserved system hostname with the system domain' do - let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } - - let(:params) do - { - host: 'host', - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - before do - VCAP::CloudController::Config.config.set(:system_domain, domain.name) - VCAP::CloudController::Config.config.set(:system_hostnames, [params[:host]]) - end - - it 'returns a 422 with a helpful error message' do - post '/v3/routes', params.to_json, admin_header - expect(last_response).to have_status_code(422) - expect(last_response).to have_error_message('Route conflicts with a reserved system route.') - end - end - - context 'when using a non-reserved hostname with the system domain' do - let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } - let(:api_call) { ->(user_headers) { post '/v3/routes', params.to_json, user_headers } } - - let(:params) do - { - host: 'host', - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - let(:route_json) do - { - guid: UUID_REGEX, - protocol: domain.protocols[0], - host: params[:host], - path: '', - port: nil, - url: "#{params[:host]}.#{domain.name}", - created_at: iso8601, - updated_at: iso8601, - destinations: [], - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - }, - metadata: { - labels: {}, - annotations: {} - }, - links: { - self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, - space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}} }, - destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, - domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } - }, - options: {} - } - end - - let(:expected_codes_and_responses) do - h = Hash.new( - { code: 403 }.freeze - ) - h['admin'] = { - code: 201, - response_object: route_json - } - h['space_developer'] = { - code: 201, - response_object: route_json - } - h['space_supporter'] = { - code: 201, - response_object: route_json - } - h - end - - before do - VCAP::CloudController::Config.config.set(:system_domain, domain.name) - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - - describe 'quotas' do - context 'when the space quota for routes is maxed out' do - let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } - let!(:space_quota_definition) { VCAP::CloudController::SpaceQuotaDefinition.make(total_routes: 0, organization: org) } - let!(:space_with_quota) { VCAP::CloudController::Space.make(space_quota_definition: space_quota_definition, organization: org) } - - let(:params_for_space_with_quota) do - { - relationships: { - space: { - data: { guid: space_with_quota.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - it 'returns a 422 with a helpful error message' do - post '/v3/routes', params_for_space_with_quota.to_json, admin_header - expect(last_response).to have_status_code(422) - expect(last_response).to have_error_message("Routes quota exceeded for space '#{space_with_quota.name}'.") - end - end - - context 'when the org quota for routes is maxed out' do - let!(:org_quota_definition) { VCAP::CloudController::QuotaDefinition.make(total_routes: 0, total_reserved_route_ports: 0) } - let!(:org_with_quota) { VCAP::CloudController::Organization.make(quota_definition: org_quota_definition) } - let!(:space_in_org_with_quota) do - VCAP::CloudController::Space.make(organization: org_with_quota) - end - let(:domain_in_org_with_quota) { VCAP::CloudController::Domain.make(owning_organization: org_with_quota) } - - let(:params_for_org_with_quota) do - { - relationships: { - space: { - data: { guid: space_in_org_with_quota.guid } - }, - domain: { - data: { guid: domain_in_org_with_quota.guid } - } - } - } - end - - it 'returns a 422 with a helpful error message' do - post '/v3/routes', params_for_org_with_quota.to_json, admin_header - expect(last_response).to have_status_code(422) - expect(last_response).to have_error_message("Routes quota exceeded for organization '#{org_with_quota.name}'.") - end - end - end - - context 'when the feature flag is disabled' do - let(:headers) { set_user_with_header_as_role(user: user, role: 'space_developer', org: org, space: space) } - let!(:feature_flag) { VCAP::CloudController::FeatureFlag.make(name: 'route_creation', enabled: false, error_message: 'my name is bob') } - let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } - let(:params) do - { - host: 'some-host', - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - context 'when the user is not an admin' do - it 'returns a 403' do - post '/v3/routes', params.to_json, headers - - expect(last_response).to have_status_code(403) - expect(parsed_response['errors'][0]['detail']).to eq('Feature Disabled: my name is bob') - end - end - - context 'when the user is an admin' do - let(:headers) { set_user_with_header_as_role(role: 'admin') } - - it 'allows creation' do - post '/v3/routes', params.to_json, headers - - expect(last_response).to have_status_code(201) - end - end - end - - context 'when the user is not logged in' do - it 'returns 401 for Unauthenticated requests' do - post '/v3/routes', {}.to_json, base_json_headers - expect(last_response).to have_status_code(401) - end - end - - context 'when the user does not have the required scopes' do - let(:user_header) { headers_for(user, scopes: ['cloud_controller.read']) } - - it 'returns a 403' do - post '/v3/routes', {}.to_json, user_header - expect(last_response).to have_status_code(403) - end - end - - context 'when the space does not exist' do - let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } - - let(:params_with_invalid_space) do - { - relationships: { - space: { - data: { guid: 'invalid-space' } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - it 'returns a 422 with a helpful error message' do - post '/v3/routes', params_with_invalid_space.to_json, admin_header - expect(last_response).to have_status_code(422) - expect(last_response).to have_error_message('Invalid space. Ensure that the space exists and you have access to it.') - end - end - - context 'when the domain does not exist' do - let(:params_with_invalid_domain) do - { - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: 'invalid-domain' } - } - } - } - end - - it 'returns a 422 with a helpful error message' do - post '/v3/routes', params_with_invalid_domain.to_json, admin_header - expect(last_response).to have_status_code(422) - expect(last_response).to have_error_message('Invalid domain. Ensure that the domain exists and you have access to it.') - end - end - - context 'when communicating with the routing API' do - let(:routing_api_client) { instance_double(VCAP::CloudController::RoutingApi::Client) } - let(:router_group) { VCAP::CloudController::RoutingApi::RouterGroup.new({ 'type' => 'tcp', 'guid' => 'some-guid' }) } - let(:headers) { set_user_with_header_as_role(role: 'admin') } - let(:domain_tcp) { VCAP::CloudController::SharedDomain.make(router_group_guid: router_group.guid, name: 'my.domain') } - let(:params) do - { - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain_tcp.guid } - } - } - } - end - - before do - allow_any_instance_of(CloudController::DependencyLocator).to receive(:routing_api_client).and_return(routing_api_client) - end - - context 'when UAA is unavailable' do - before do - allow(routing_api_client).to receive(:router_group).and_raise VCAP::CloudController::RoutingApi::UaaUnavailable - end - - it 'returns a 503 with a helpful error message' do - post '/v3/routes', params.to_json, headers - - expect(last_response).to have_status_code(503) - expect(parsed_response['errors'][0]['detail']).to eq 'Communicating with the Routing API failed because UAA is currently unavailable. Please try again later.' - end - end - - context 'when the routing API is unavailable' do - before do - allow(routing_api_client).to receive(:enabled?).and_return true - allow(routing_api_client).to receive(:router_group).and_raise VCAP::CloudController::RoutingApi::RoutingApiUnavailable - end - - it 'returns a 503 with a helpful error message' do - post '/v3/routes', params.to_json, headers - - expect(last_response).to have_status_code(503) - expect(parsed_response['errors'][0]['detail']).to eq 'The Routing API is currently unavailable. Please try again later.' - end - end - - context 'when the routing API is disabled' do - before do - allow(routing_api_client).to receive(:enabled?).and_return false - allow(routing_api_client).to receive(:router_group).and_raise VCAP::CloudController::RoutingApi::RoutingApiDisabled - end - - it 'returns a 503 with a helpful error message' do - post '/v3/routes', params.to_json, headers - - expect(last_response).to have_status_code(503) - expect(parsed_response['errors'][0]['detail']).to eq 'The Routing API is disabled.' - end - end - - context 'when the router group is unavailable' do - let(:domain_tcp) { VCAP::CloudController::SharedDomain.make(router_group_guid: 'not a guid', name: 'my.domain') } - - before do - allow(routing_api_client).to receive_messages(enabled?: true, router_group: nil) - end - - it 'returns a 503 with a helpful error message' do - post '/v3/routes', params.to_json, headers - - expect(last_response.status).to eq(422) - expect(parsed_response['errors'][0]['detail']).to eq 'Route could not be created because the specified domain does not have a valid router group.' - end - end - end - end - - describe 'PATCH /v3/routes/:guid' do - let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } - let(:route) { VCAP::CloudController::Route.make(space: space, domain: domain, host: '') } - let(:api_call) { ->(user_headers) { patch "/v3/routes/#{route.guid}", params.to_json, user_headers } } - let(:params) do - { - metadata: { - labels: { - potato: 'fingerling', - style: 'roasted' - }, - annotations: { - potato: 'russet', - style: 'fried' - } - } - } - end - - let(:route_json) do - { - guid: UUID_REGEX, - protocol: domain.protocols[0], - host: '', - path: '', - port: nil, - url: domain.name, - created_at: iso8601, - updated_at: iso8601, - destinations: [], - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - }, - links: { - self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, - space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}} }, - destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, - domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } - }, - metadata: { - labels: { - potato: 'fingerling', - style: 'roasted' - }, - annotations: { - potato: 'russet', - style: 'fried' - } - }, - options: {} - } - end - - context 'when the user logged in' do - let(:expected_codes_and_responses) do - h = Hash.new({ code: 403, errors: CF_NOT_AUTHORIZED }.freeze) - h['admin'] = { code: 200, response_object: route_json } - h['no_role'] = { code: 404 } - h['org_billing_manager'] = { code: 404 } - h['space_developer'] = { code: 200, response_object: route_json } - h['space_supporter'] = { code: 200, response_object: route_json } - h - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - - context 'when organization is suspended' do - let(:expected_codes_and_responses) do - h = super() - %w[space_developer space_supporter].each { |r| h[r] = { code: 403, errors: CF_ORG_SUSPENDED } } - h - end - - before do - org.update(status: VCAP::CloudController::Organization::SUSPENDED) - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - end - - context 'when the user is not a member in the routes org' do - let(:other_space) { VCAP::CloudController::Space.make } - let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: other_space.organization) } - let(:route) { VCAP::CloudController::Route.make(space: other_space, domain: domain, host: '') } - - let(:route_json) do - { - guid: UUID_REGEX, - protocol: domain.protocols[0], - host: '', - path: '', - port: nil, - url: domain.name, - created_at: iso8601, - updated_at: iso8601, - destinations: [], - relationships: { - space: { - data: { guid: other_space.guid } - }, - domain: { - data: { guid: domain.guid } - } - }, - links: { - self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, - space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{other_space.guid}} }, - destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, - domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } - }, - metadata: { - labels: { - potato: 'fingerling', - style: 'roasted' - }, - annotations: { - potato: 'russet', - style: 'fried' - } - }, - options: {} - } - end - let(:expected_codes_and_responses) do - h = Hash.new({ code: 404 }.freeze) - h['admin'] = { - code: 200, - response_object: route_json - } - h['admin_read_only'] = { - code: 403 - } - h['global_auditor'] = { - code: 403 - } - h - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - - context 'when route does not exist' do - it 'returns a 404 with a helpful error message' do - patch "/v3/routes/#{user.guid}", params.to_json, admin_header - - expect(last_response).to have_status_code(404) - expect(last_response).to have_error_message('Route not found') - end - end - - context 'when request input message is invalid' do - let(:params_with_invalid_input) do - { - disallowed_key: 'val' - } - end - - it 'returns a 422' do - patch "/v3/routes/#{route.guid}", params_with_invalid_input.to_json, admin_header - - expect(last_response).to have_status_code(422) - end - end - - context 'when metadata is given with invalid format' do - let(:params_with_invalid_metadata_format) do - { - metadata: { - labels: { - "": 'mashed', - '/potato': '.value.' - } - } - } - end - - it 'returns a 422' do - patch "/v3/routes/#{route.guid}", params_with_invalid_metadata_format.to_json, admin_header - - expect(last_response).to have_status_code(422) - expect(parsed_response['errors'][0]['detail']).to match(/Metadata [\w\s]+ error/) - end - end - - context 'when the user is not logged in' do - it 'returns 401 for Unauthenticated requests' do - patch "/v3/routes/#{route.guid}", nil, base_json_headers - expect(last_response).to have_status_code(401) - end - end - end - - describe 'DELETE /v3/routes/:guid' do - let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } - let(:route) { VCAP::CloudController::Route.make(space:, domain:) } - let(:api_call) { ->(user_headers) { delete "/v3/routes/#{route.guid}", nil, user_headers } } - let(:db_check) do - lambda do - expect(last_response.headers['Location']).to match(%r{http.+/v3/jobs/[a-fA-F0-9-]+}) - - execute_all_jobs(expected_successes: 1, expected_failures: 0) - get "/v3/routes/#{route.guid}", {}, admin_headers - expect(last_response).to have_status_code(404) - end - end - - context 'deleting metadata' do - it_behaves_like 'resource with metadata' do - let(:resource) { route } - let(:api_call) do - -> { delete "/v3/routes/#{route.guid}", nil, admin_header } - end - end - end - - context 'when the user is a member in the routes org' do - let(:expected_codes_and_responses) do - h = Hash.new({ code: 403, errors: CF_NOT_AUTHORIZED }.freeze) - h['org_billing_manager'] = { code: 404 } - h['no_role'] = { code: 404 } - h['admin'] = { code: 202 } - h['space_developer'] = { code: 202 } - h['space_supporter'] = { code: 202 } - h - end - - it_behaves_like 'permissions for delete endpoint', ALL_PERMISSIONS do - let(:expected_event_hash) do - { - type: 'audit.route.delete-request', - actee: route.guid, - actee_type: 'route', - actee_name: route.host, - metadata: { request: { recursive: true } }.to_json, - space_guid: space.guid, - organization_guid: org.guid - } - end - end - - context 'when organization is suspended' do - let(:expected_codes_and_responses) do - h = super() - %w[space_developer space_supporter].each { |r| h[r] = { code: 403, errors: CF_ORG_SUSPENDED } } - h - end - - before do - org.update(status: VCAP::CloudController::Organization::SUSPENDED) - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - end - - describe 'when the user is not logged in' do - it 'returns 401 for Unauthenticated requests' do - delete "/v3/routes/#{route.guid}", nil, base_json_headers - expect(last_response).to have_status_code(401) - end - end - end - - describe 'GET /v3/routes/:guid/relationships/shared_spaces' do - let(:api_call) { ->(user_headers) { get "/v3/routes/#{guid}/relationships/shared_spaces", nil, user_headers } } - let(:target_space_1) { VCAP::CloudController::Space.make(organization: org) } - let(:route) do - route = VCAP::CloudController::Route.make(space:) - route.add_shared_space(target_space_1) - route - end - let(:guid) { route.guid } - let(:space_dev_headers) do - org.add_user(user) - space.add_developer(user) - headers_for(user) - end - let!(:feature_flag) do - VCAP::CloudController::FeatureFlag.make(name: 'route_sharing', enabled: true, error_message: nil) - end - - before do - org.add_user(user) - target_space_1.add_developer(user) - end - - describe 'permissions' do - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS do - let(:expected_codes_and_responses) do - h = Hash.new({ code: 200, response_object: { - data: [ - { - guid: target_space_1.guid - } - ], - links: { - self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{route.guid}/relationships/shared_spaces} } - } - } }.freeze) - - h['org_billing_manager'] = { code: 404 } - h['no_role'] = { code: 404 } - h - end - end - end - - describe 'when route_sharing flag is disabled' do - before do - feature_flag.enabled = false - feature_flag.save - end - - it 'makes users unable to unshare routes' do - api_call.call(space_dev_headers) - - expect(last_response).to have_status_code(403) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => 'Feature Disabled: route_sharing', - 'title' => 'CF-FeatureDisabled', - 'code' => 330_002 - } - ) - ) - end - end - - it 'responds with 404 when the route does not exist' do - get '/v3/routes/some-fake-guid/relationships/shared_spaces', nil, space_dev_headers - - expect(last_response).to have_status_code(404) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => 'Route not found', - 'title' => 'CF-ResourceNotFound' - } - ) - ) - end - end - - describe 'POST /v3/routes/:guid/relationships/shared_spaces' do - let(:api_call) { ->(user_headers) { post "/v3/routes/#{guid}/relationships/shared_spaces", request_body.to_json, user_headers } } - let(:target_space_1) { VCAP::CloudController::Space.make(organization: org) } - let(:target_space_2) { VCAP::CloudController::Space.make(organization: org) } - let(:request_body) do - { - 'data' => [ - { 'guid' => target_space_1.guid }, - { 'guid' => target_space_2.guid } - ] - } - end - let(:route) { VCAP::CloudController::Route.make(space:) } - let(:guid) { route.guid } - let(:space_dev_headers) do - org.add_user(user) - space.add_developer(user) - headers_for(user) - end - let!(:feature_flag) do - VCAP::CloudController::FeatureFlag.make(name: 'route_sharing', enabled: true, error_message: nil) - end - - before do - org.add_user(user) - target_space_1.add_developer(user) - target_space_2.add_developer(user) - end - - describe 'permissions' do - let(:expected_codes_and_responses) do - h = Hash.new({ code: 403, errors: CF_NOT_AUTHORIZED }.freeze) - - h['org_billing_manager'] = { code: 404 } - h['no_role'] = { code: 404 } - - h['admin'] = { code: 200 } - h['space_developer'] = { code: 200 } - h['space_supporter'] = { code: 200 } - h - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - - context 'when organization is suspended' do - let(:expected_codes_and_responses) do - h = super() - %w[space_developer space_supporter].each { |r| h[r] = { code: 403, errors: CF_ORG_SUSPENDED } } - h - end - - before do - org.update(status: VCAP::CloudController::Organization::SUSPENDED) - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - - context 'when target organization is suspended' do - let(:target_space_1) do - space = VCAP::CloudController::Space.make - space.organization.add_user(user) - space.organization.update(status: VCAP::CloudController::Organization::SUSPENDED) - space - end - - let(:expected_codes_and_responses) do - h = super() - %w[space_developer space_supporter].each { |r| h[r] = { code: 422 } } - h - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - end - - it 'shares the route to the target space and logs audit event' do - api_call.call(space_dev_headers) - - expect(last_response.status).to eq(200) - - event = VCAP::CloudController::Event.last - expect(event.values).to include({ - type: 'audit.route.share', - actor: user.guid, - actee_type: 'route', - actee_name: route.host, - space_guid: space.guid, - organization_guid: space.organization.guid - }) - expect(event.metadata['target_space_guids']).to include(target_space_1.guid, target_space_2.guid) - - route.reload - expect(route.shared_spaces).to include(target_space_1, target_space_2) - end - - it 'reports that the route is now shared' do - api_call.call(space_dev_headers) - - expect(last_response.status).to eq(200) - route.reload - expect(route.shared_spaces).to include(target_space_1, target_space_2) - expect(route).to be_shared - end - - it 'reports that the route is not shared when it has not been shared' do - route.reload - expect(route.shared_spaces).to be_empty - expect(route).not_to be_shared - end - - describe 'when route_sharing flag is disabled' do - before do - feature_flag.enabled = false - feature_flag.save - end - - it 'makes users unable to share routes' do - api_call.call(space_dev_headers) - - expect(last_response).to have_status_code(403) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => 'Feature Disabled: route_sharing', - 'title' => 'CF-FeatureDisabled', - 'code' => 330_002 - } - ) - ) - end - end - - it 'responds with 404 when the route does not exist' do - post '/v3/routes/some-fake-guid/relationships/shared_spaces', request_body.to_json, space_dev_headers - - expect(last_response).to have_status_code(404) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => 'Route not found', - 'title' => 'CF-ResourceNotFound' - } - ) - ) - end - - describe 'when the request body is invalid' do - context 'when it is not a valid relationship' do - let(:request_body) do - { - 'data' => { 'guid' => target_space_1.guid } - } - end - - it 'responds with 422' do - api_call.call(space_dev_headers) - - expect(last_response.status).to eq(422) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => 'Data must be an array', - 'title' => 'CF-UnprocessableEntity' - } - ) - ) - end - end - - context 'when there are additional keys' do - let(:request_body) do - { - 'data' => [ - { 'guid' => target_space_1.guid } - ], - 'fake-key' => 'foo' - } - end - - it 'responds with 422' do - api_call.call(space_dev_headers) - - expect(last_response.status).to eq(422) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => "Unknown field(s): 'fake-key'", - 'title' => 'CF-UnprocessableEntity' - } - ) - ) - end - end - end - - describe 'target space to share to' do - context 'does not exist' do - let(:target_space_guid) { 'fake-target' } - let(:request_body) do - { - 'data' => [ - { 'guid' => target_space_guid } - ] - } - end - - it 'responds with 422' do - api_call.call(space_dev_headers) - - expect(last_response.status).to eq(422) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => "Unable to share route #{route.uri} with spaces ['#{target_space_guid}']. " \ - 'Ensure the spaces exist and that you have access to them.', - 'title' => 'CF-UnprocessableEntity' - } - ) - ) - end - end - - context 'user does not have access to one of the target spaces' do - let(:no_access_target_space) { VCAP::CloudController::Space.make(organization: org) } - let(:request_body) do - { - 'data' => [ - { 'guid' => no_access_target_space.guid }, - { 'guid' => target_space_1.guid } - ] - } - end - - it 'responds with 422 and does not share the route' do - api_call.call(space_dev_headers) - - expect(last_response.status).to eq(422) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => "Unable to share route #{route.uri} with spaces ['#{no_access_target_space.guid}']. " \ - 'Ensure the spaces exist and that you have access to them.', - 'title' => 'CF-UnprocessableEntity' - } - ) - ) - - route.reload - expect(route).not_to be_shared - end - end - - context 'already owns the route' do - let(:request_body) do - { - 'data' => [ - { 'guid' => space.guid }, - { 'guid' => target_space_1.guid } - ] - } - end - - it 'responds with 422 and does not share the route' do - api_call.call(space_dev_headers) - - expect(last_response.status).to eq(422) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => "Unable to share route '#{route.uri}' with space '#{space.guid}'. " \ - 'Routes cannot be shared into the space where they were created.', - 'title' => 'CF-UnprocessableEntity' - } - ) - ) - - route.reload - expect(route).not_to be_shared - end - end - end - - describe 'errors while sharing' do - # isolation segments? - end - end - - describe 'DELETE /v3/routes/:guid/relationships/shared_spaces/:space_guid' do - let(:api_call) { ->(user_headers) { delete "/v3/routes/#{guid}/relationships/shared_spaces/#{unshared_space_guid}", request_body.to_json, user_headers } } - let(:target_space_1) { VCAP::CloudController::Space.make(organization: org) } - let(:target_space_2) { VCAP::CloudController::Space.make(organization: org) } - let(:target_space_3) { VCAP::CloudController::Space.make(organization: org) } - let(:target_space_not_shared_with_route) { VCAP::CloudController::Space.make(organization: org) } - let(:space_to_unshare) { target_space_2 } - let(:unshared_space_guid) { space_to_unshare.guid } - let(:request_body) { {} } - let(:route) do - route = VCAP::CloudController::Route.make(space:) - route.add_shared_space(target_space_1) - route.add_shared_space(target_space_2) - route.add_shared_space(target_space_3) - route - end - let(:guid) { route.guid } - let(:space_dev_headers) do - org.add_user(user) - space.add_developer(user) - headers_for(user) - end - let!(:feature_flag) do - VCAP::CloudController::FeatureFlag.make(name: 'route_sharing', enabled: true, error_message: nil) - end - - before do - org.add_user(user) - target_space_1.add_developer(user) - target_space_2.add_developer(user) - target_space_not_shared_with_route.add_developer(user) - end - - describe 'permissions' do - let(:expected_codes_and_responses) do - h = Hash.new({ code: 403, errors: CF_NOT_AUTHORIZED }.freeze) - - h['org_billing_manager'] = { code: 404 } - h['no_role'] = { code: 404 } - - h['admin'] = { code: 204 } - h['space_developer'] = { code: 204 } - h['space_supporter'] = { code: 204 } - h - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - - context 'when organization is suspended' do - let(:expected_codes_and_responses) do - h = super() - %w[space_developer space_supporter].each { |r| h[r] = { code: 403, errors: CF_ORG_SUSPENDED } } - h - end - - before do - org.update(status: VCAP::CloudController::Organization::SUSPENDED) - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - - context 'when target organization is suspended' do - let(:space_to_unshare) do - space = VCAP::CloudController::Space.make - space.organization.add_user(user) - space.add_developer(user) - space.organization.update(status: VCAP::CloudController::Organization::SUSPENDED) - space - end - - let(:expected_codes_and_responses) do - h = super() - %w[space_developer space_supporter].each do |r| - h[r] = { - code: 422, - errors: [{ - detail: "Unable to unshare route '#{route.uri}' from space '#{space_to_unshare.guid}'. The target organization is suspended.", - title: 'CF-UnprocessableEntity', - code: 10_008 - }] - } - end - h - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - end - - it 'unshares the specified route from the target space and logs audit event' do - expect(route.shared_spaces).to include(target_space_1, space_to_unshare, target_space_3) - - api_call.call(space_dev_headers) - - expect(last_response.status).to eq(204) - - event = VCAP::CloudController::Event.last - expect(event.values).to include({ - type: 'audit.route.unshare', - actor: user.guid, - actee_type: 'route', - actee_name: route.host, - space_guid: space.guid, - organization_guid: space.organization.guid - }) - expect(event.metadata['target_space_guid']).to eq(unshared_space_guid) - - route.reload - expect(route.shared_spaces).to include(target_space_1, target_space_3) - end - - describe 'when route_sharing flag is disabled' do - before do - feature_flag.enabled = false - feature_flag.save - end - - it 'makes users unable to unshare routes' do - api_call.call(space_dev_headers) - - expect(last_response).to have_status_code(403) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => 'Feature Disabled: route_sharing', - 'title' => 'CF-FeatureDisabled', - 'code' => 330_002 - } - ) - ) - end - end - - it 'responds with 204 when the route is not shared with the specified space' do - delete "/v3/routes/#{route.guid}/relationships/shared_spaces/#{target_space_not_shared_with_route.guid}", request_body.to_json, space_dev_headers - - expect(last_response.status).to eq(204) - end - - it "responds with 404 when the route doesn't exist" do - delete "/v3/routes/some-fake-guid/relationships/shared_spaces/#{target_space_1.guid}", request_body.to_json, space_dev_headers - - expect(last_response).to have_status_code(404) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => 'Route not found', - 'title' => 'CF-ResourceNotFound' - } - ) - ) - end - - context 'attempting to unshare from space that owns us' do - let(:space_to_unshare) { space } - - it 'responds with 422 and does not unshare the roue' do - api_call.call(space_dev_headers) - - expect(last_response.status).to eq(422) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => "Unable to unshare route '#{route.uri}' from space " \ - "'#{space.guid}'. Routes cannot be removed from the space that owns them.", - 'title' => 'CF-UnprocessableEntity' - } - ) - ) - - route.reload - expect(route.shared_spaces).to contain_exactly(target_space_1, target_space_2, target_space_3) - end - end - - describe 'target space to unshare with' do - context 'does not exist' do - let(:unshared_space_guid) { 'fake-target' } - - it 'responds with 422' do - api_call.call(space_dev_headers) - - expect(last_response.status).to eq(422) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => "Unable to unshare route '#{route.uri}' from space '#{unshared_space_guid}'. " \ - 'Ensure the space exists and that you have access to it.', - 'title' => 'CF-UnprocessableEntity' - } - ) - ) - end - end - - context 'user does not have read access to the target space' do - let(:unshared_space_guid) { VCAP::CloudController::Space.make(organization: org).guid } - - it 'responds with 422 and does not share the route' do - api_call.call(space_dev_headers) - - expect(last_response.status).to eq(422) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => "Unable to unshare route '#{route.uri}' from space '#{unshared_space_guid}'. " \ - 'Ensure the space exists and that you have access to it.', - 'title' => 'CF-UnprocessableEntity' - } - ) - ) - end - end - - context 'user does not have write access to the target space' do - let(:no_write_access_target_space) { VCAP::CloudController::Space.make(organization: org) } - let(:unshared_space_guid) { no_write_access_target_space.guid } - - before do - no_write_access_target_space.add_auditor(user) - end - - it 'responds with 422 and does not share the route' do - api_call.call(space_dev_headers) - - expect(last_response.status).to eq(422) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => "Unable to unshare route '#{route.uri}' from space '#{no_write_access_target_space.guid}'. " \ - "You don't have write permission for the target space.", - 'title' => 'CF-UnprocessableEntity' - } - ) - ) - end - end - end - end - - describe 'PATCH /v3/routes/:guid/relationships/space' do - let(:shared_domain) { VCAP::CloudController::SharedDomain.make } - let(:route) { VCAP::CloudController::Route.make(space: space, domain: shared_domain) } - let(:api_call) { ->(user_headers) { patch "/v3/routes/#{route.guid}/relationships/space", request_body.to_json, user_headers } } - let(:target_space) { VCAP::CloudController::Space.make(organization: org) } - let(:request_body) do - { - data: { 'guid' => target_space.guid } - } - end - let(:space_dev_headers) do - org.add_user(user) - space.add_developer(user) - headers_for(user) - end - let!(:feature_flag) do - VCAP::CloudController::FeatureFlag.make(name: 'route_sharing', enabled: true, error_message: nil) - end - - before do - org.add_user(user) - target_space.add_developer(user) - end - - context 'when the user logged in' do - let(:expected_codes_and_responses) do - h = Hash.new({ code: 403, errors: CF_NOT_AUTHORIZED }.freeze) - h['admin'] = { code: 200 } - h['no_role'] = { code: 404 } - h['org_billing_manager'] = { code: 404 } - h['space_developer'] = { code: 200 } - h - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - - context 'when organization is suspended' do - let(:expected_codes_and_responses) do - h = super() - %w[space_developer].each { |r| h[r] = { code: 403, errors: CF_ORG_SUSPENDED } } - h - end - - before do - org.update(status: VCAP::CloudController::Organization::SUSPENDED) - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - - context 'when target organization is suspended' do - let(:suspended_space) { VCAP::CloudController::Space.make } - let(:request_body) do - { - data: { 'guid' => suspended_space.guid } - } - end - - let(:expected_codes_and_responses) do - h = super() - %w[space_developer].each do |r| - h[r] = { - code: 422, - errors: [{ - detail: "Unable to transfer owner of route '#{route.uri}' to space '#{suspended_space.guid}'. The target organization is suspended.", - title: 'CF-UnprocessableEntity', - code: 10_008 - }] - } - end - h - end - - before do - suspended_space.organization.add_user(user) - suspended_space.add_developer(user) - suspended_space.organization.update(status: VCAP::CloudController::Organization::SUSPENDED) - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - end - - it 'changes the route owner to the given space and logs an event', isolation: :truncation do - api_call.call(space_dev_headers) - - expect(last_response.status).to eq(200) - - event = VCAP::CloudController::Event.last - expect(event.values).to include({ - type: 'audit.route.transfer-owner', - actor: user.guid, - actee_type: 'route', - actee_name: route.host, - space_guid: space.guid, - organization_guid: space.organization.guid - }) - expect(event.metadata['target_space_guid']).to eq(target_space.guid) - - route.reload - expect(route.space).to eq target_space - end - - describe 'when using a private domain' do - let(:private_domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: org) } - let(:route) { VCAP::CloudController::Route.make(space: space, domain: private_domain) } - let(:second_org) { VCAP::CloudController::Organization.make } - let(:another_space) { VCAP::CloudController::Space.make(organization: second_org) } - let(:request_body) do - { - data: { 'guid' => another_space.guid } - } - end - let(:space_dev_headers) do - org.add_user(user) - space.add_developer(user) - second_org.add_user(user) - another_space.add_developer(user) - headers_for(user) - end - - it 'responds with 422' do - api_call.call(space_dev_headers) - - expect(last_response.status).to eq(422) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => "Unable to transfer owner of route '#{route.uri}' to space '#{another_space.guid}'. " \ - "Target space does not have access to route's domain", - 'title' => 'CF-UnprocessableEntity' - } - ) - ) - end - end - - describe 'target space to transfer to' do - context 'does not exist' do - let(:target_space_guid) { 'fake-target' } - let(:request_body) do - { - data: { 'guid' => target_space_guid } - } - end - - it 'responds with 422' do - api_call.call(space_dev_headers) - - expect(last_response.status).to eq(422) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => "Unable to transfer owner of route '#{route.uri}' to space '#{target_space_guid}'. " \ - 'Ensure the space exists and that you have access to it.', - 'title' => 'CF-UnprocessableEntity' - } - ) - ) - end - end - - context 'user does not have read access to the target space' do - let(:no_access_target_space) { VCAP::CloudController::Space.make(organization: org) } - let(:request_body) do - { - data: { 'guid' => no_access_target_space.guid } - } - end - - it 'responds with 422 and does not share the route' do - api_call.call(space_dev_headers) - - expect(last_response.status).to eq(422) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => "Unable to transfer owner of route '#{route.uri}' to space '#{no_access_target_space.guid}'. " \ - 'Ensure the space exists and that you have access to it.', - 'title' => 'CF-UnprocessableEntity' - } - ) - ) - end - end - - context 'user does not have write access to the target space' do - let(:no_write_access_target_space) { VCAP::CloudController::Space.make(organization: org) } - let(:request_body) do - { - data: { 'guid' => no_write_access_target_space.guid } - } - end - - before do - no_write_access_target_space.add_auditor(user) - end - - it 'responds with 422 and does not share the route' do - api_call.call(space_dev_headers) - - expect(last_response.status).to eq(422) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => "Unable to transfer owner of route '#{route.uri}' to space '#{no_write_access_target_space.guid}'. " \ - "You don't have write permission for the target space.", - 'title' => 'CF-UnprocessableEntity' - } - ) - ) - end - end - end - - it 'responds with 404 when the route does not exist' do - patch '/v3/routes/some-fake-guid/relationships/space', request_body.to_json, space_dev_headers - - expect(last_response).to have_status_code(404) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => 'Route not found', - 'title' => 'CF-ResourceNotFound' - } - ) - ) - end - - describe 'when the request body is invalid' do - context 'when there are additional keys' do - let(:request_body) do - { - data: { 'guid' => target_space.guid }, - 'fake-key' => 'foo' - } - end - - it 'responds with 422' do - api_call.call(space_dev_headers) - - expect(last_response.status).to eq(422) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => "Unknown field(s): 'fake-key'", - 'title' => 'CF-UnprocessableEntity' - } - ) - ) - end - end - - context 'when data is not a hash' do - let(:request_body) do - { - data: [{ 'guid' => target_space.guid }] - } - end - - it 'responds with 422' do - api_call.call(space_dev_headers) - - expect(last_response.status).to eq(422) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => 'Data must be an object', - 'title' => 'CF-UnprocessableEntity' - } - ) - ) - end - end - end - - describe 'when route_sharing flag is disabled' do - before do - feature_flag.enabled = false - feature_flag.save - end - - it 'makes users unable to transfer-owner' do - api_call.call(space_dev_headers) - - expect(last_response).to have_status_code(403) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => 'Feature Disabled: route_sharing', - 'title' => 'CF-FeatureDisabled', - 'code' => 330_002 - } - ) - ) - end - end - end - - describe 'GET /v3/apps/:app_guid/routes' do - let(:app_model) { VCAP::CloudController::AppModel.make(space:) } - let(:route1) { VCAP::CloudController::Route.make(space:) } - let(:route2) { VCAP::CloudController::Route.make(space:) } - let!(:route3) { VCAP::CloudController::Route.make(space:) } - let!(:route_mapping1) { VCAP::CloudController::RouteMappingModel.make(app: app_model, route: route1, process_type: 'web') } - let!(:route_mapping2) { VCAP::CloudController::RouteMappingModel.make(app: app_model, route: route2, process_type: 'admin') } - let(:api_call) { ->(user_headers) { get "/v3/apps/#{app_model.guid}/routes", nil, user_headers } } - - let(:route1_json) do - { - guid: route1.guid, - protocol: route1.domain.protocols[0], - host: route1.host, - path: route1.path, - port: nil, - url: "#{route1.host}.#{route1.domain.name}#{route1.path}", - created_at: iso8601, - updated_at: iso8601, - destinations: contain_exactly({ - guid: route_mapping1.guid, - app: { - guid: app_model.guid, - process: { - type: route_mapping1.process_type - } - }, - weight: route_mapping1.weight, - port: route_mapping1.presented_port, - protocol: 'http1', - created_at: iso8601, - updated_at: iso8601 - }), - relationships: { - space: { - data: { guid: route1.space.guid } - }, - domain: { - data: { guid: route1.domain.guid } - } - }, - metadata: { - labels: {}, - annotations: {} - }, - links: { - self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{route1.guid}} }, - space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{route1.space.guid}} }, - destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{route1.guid}/destinations} }, - domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{route1.domain.guid}} } - }, - options: {} - } - end - - let(:route2_json) do - { - guid: route2.guid, - protocol: route2.domain.protocols[0], - host: route2.host, - path: route2.path, - port: nil, - url: "#{route2.host}.#{route2.domain.name}#{route2.path}", - created_at: iso8601, - updated_at: iso8601, - destinations: contain_exactly({ - guid: route_mapping2.guid, - app: { - guid: app_model.guid, - process: { - type: route_mapping2.process_type - } - }, - weight: route_mapping2.weight, - port: route_mapping2.presented_port, - protocol: 'http1', - created_at: iso8601, - updated_at: iso8601 - }), - relationships: { - space: { - data: { guid: route2.space.guid } - }, - domain: { - data: { guid: route2.domain.guid } - } - }, - metadata: { - labels: {}, - annotations: {} - }, - links: { - self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{route2.guid}} }, - space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{route2.space.guid}} }, - destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{route2.guid}/destinations} }, - domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{route2.domain.guid}} } - }, - options: {} - } - end - - context 'when the user is a member in the app space' do - let(:expected_codes_and_responses) do - h = Hash.new( - { code: 200, - response_objects: [route1_json, route2_json] }.freeze - ) - - h['org_auditor'] = { code: 404 } - h['org_billing_manager'] = { code: 404 } - h['no_role'] = { code: 404 } - h - end - - it_behaves_like 'permissions for list endpoint', ALL_PERMISSIONS - end - - context 'ports filter' do - # Don't even think of converting the following hash to symbols ('type' => 'tcp' NOT type: 'tcp'), and you need to set the GUID - let(:router_group) { VCAP::CloudController::RoutingApi::RouterGroup.new({ 'type' => 'tcp', 'reservable_ports' => '7777,8888,9999', 'guid' => 'some-guid' }) } - let(:routing_api_client) { instance_double(VCAP::CloudController::RoutingApi::Client) } - - before do - allow_any_instance_of(CloudController::DependencyLocator).to receive(:routing_api_client).and_return(routing_api_client) - allow(routing_api_client).to receive_messages(enabled?: true, router_group: router_group) - end - - context 'when there are multiple TCP routes with different ports' do - # The following `let`s depend on the above `before do` - let(:domain_tcp) { VCAP::CloudController::SharedDomain.make(router_group_guid: router_group.guid, name: 'my.domain') } - let!(:route_with_ports_0) do - VCAP::CloudController::Route.make(host: '', space: space, domain: domain_tcp, guid: 'route-with-port-0', port: 7777) - end - let!(:route_with_ports_1) do - VCAP::CloudController::Route.make(host: '', space: space, domain: domain_tcp, guid: 'route-with-port-1', port: 8888) - end - let!(:route_with_ports_2) do - VCAP::CloudController::Route.make(host: '', space: space, domain: domain_tcp, guid: 'route-with-port-2', port: 9999) - end - let!(:route_mapping_1) { VCAP::CloudController::RouteMappingModel.make(app: app_model, route: route_with_ports_1, process_type: 'web') } - let!(:route_mapping_2) { VCAP::CloudController::RouteMappingModel.make(app: app_model, route: route_with_ports_2, process_type: 'web') } - - it 'returns routes filtered by ports' do - get "/v3/apps/#{app_model.guid}/routes?ports=7777,8888", nil, admin_header - expect(last_response).to have_status_code(200) - expect(parsed_response['resources'].size).to eq(1) - expect(parsed_response['resources'].first['port']).to eq(route_with_ports_1.port) - end - end - end - - describe 'eager loading' do - it 'eager loads associated resources that the presenter specifies' do - expect(VCAP::CloudController::RouteFetcher).to receive(:fetch).with( - anything, - hash_including(eager_loaded_associations: %i[domain space route_mappings labels annotations]) - ).and_call_original - - get "/v3/apps/#{app_model.guid}/routes", nil, admin_header - expect(last_response).to have_status_code(200) - end - end - end -end From 09b217dac67845aa20720f421535c13b1805689c Mon Sep 17 00:00:00 2001 From: johha Date: Mon, 2 Mar 2026 16:18:09 +0100 Subject: [PATCH 12/20] Revert file splits - no benefit without CI parallelization changes Reverts the apps_spec.rb and routes_spec.rb splits since they don't provide performance benefits with the current CI setup. The splits only help when the CI is configured to run spec files in parallel across workers. Focus optimization efforts on: - lightweight_spec_helper/db_spec_helper conversions (reduce load time) - Test data optimization (let! -> let) - Reducing database operations --- spec/request/apps/actions_spec.rb | 664 --- spec/request/apps/builds_and_ssh_spec.rb | 222 - spec/request/apps/create_spec.rb | 451 -- spec/request/apps/delete_and_update_spec.rb | 329 -- spec/request/apps/droplet_spec.rb | 329 -- spec/request/apps/environment_spec.rb | 178 - spec/request/apps/list_spec.rb | 940 ----- spec/request/apps/shared_context.rb | 10 - spec/request/apps/show_spec.rb | 495 --- spec/request/apps_spec.rb | 3542 ++++++++++++++++ spec/request/routes/apps_routes_spec.rb | 173 - spec/request/routes/create_spec.rb | 1290 ------ spec/request/routes/list_spec.rb | 938 ----- spec/request/routes/shared_context.rb | 31 - spec/request/routes/sharing_spec.rb | 918 ---- spec/request/routes/show_spec.rb | 172 - spec/request/routes/update_and_delete_spec.rb | 278 -- spec/request/routes_spec.rb | 3748 +++++++++++++++++ 18 files changed, 7290 insertions(+), 7418 deletions(-) delete mode 100644 spec/request/apps/actions_spec.rb delete mode 100644 spec/request/apps/builds_and_ssh_spec.rb delete mode 100644 spec/request/apps/create_spec.rb delete mode 100644 spec/request/apps/delete_and_update_spec.rb delete mode 100644 spec/request/apps/droplet_spec.rb delete mode 100644 spec/request/apps/environment_spec.rb delete mode 100644 spec/request/apps/list_spec.rb delete mode 100644 spec/request/apps/shared_context.rb delete mode 100644 spec/request/apps/show_spec.rb create mode 100644 spec/request/apps_spec.rb delete mode 100644 spec/request/routes/apps_routes_spec.rb delete mode 100644 spec/request/routes/create_spec.rb delete mode 100644 spec/request/routes/list_spec.rb delete mode 100644 spec/request/routes/shared_context.rb delete mode 100644 spec/request/routes/sharing_spec.rb delete mode 100644 spec/request/routes/show_spec.rb delete mode 100644 spec/request/routes/update_and_delete_spec.rb create mode 100644 spec/request/routes_spec.rb diff --git a/spec/request/apps/actions_spec.rb b/spec/request/apps/actions_spec.rb deleted file mode 100644 index 344dbb5f587..00000000000 --- a/spec/request/apps/actions_spec.rb +++ /dev/null @@ -1,664 +0,0 @@ -require 'spec_helper' -require 'actions/process_create_from_app_droplet' -require 'request_spec_shared_examples' -require_relative 'shared_context' - -# Split from spec/request/apps_spec.rb for better test parallelization - -RSpec.describe 'Apps' do - include_context 'apps request spec' - - describe 'POST /v3/apps/:guid/actions/start' do - let(:stack) { VCAP::CloudController::Stack.make(name: 'stack-name') } - let(:app_model) do - VCAP::CloudController::AppModel.make( - :buildpack, - name: 'app-name', - space: space, - desired_state: 'STOPPED' - ) - end - - context 'app lifecycle is buildpack' do - let!(:droplet) do - VCAP::CloudController::DropletModel.make( - :buildpack, - app: app_model, - state: VCAP::CloudController::DropletModel::STAGED_STATE - ) - end - - before do - app_model.lifecycle_data.buildpacks = ['http://example.com/git'] - app_model.lifecycle_data.stack = stack.name - app_model.lifecycle_data.save - app_model.droplet = droplet - app_model.save - end - - context 'starting an app' do - let(:api_call) { ->(user_headers) { post "/v3/apps/#{app_model.guid}/actions/start", nil, user_headers } } - let(:app_start_response_object) do - { - 'name' => 'app-name', - 'guid' => app_model.guid, - 'state' => 'STARTED', - 'created_at' => iso8601, - 'updated_at' => iso8601, - 'metadata' => { 'labels' => {}, 'annotations' => {} }, - 'lifecycle' => { - 'type' => 'buildpack', - 'data' => { - 'buildpacks' => ['http://example.com/git'], - 'stack' => 'stack-name' - } - }, - 'relationships' => { - 'space' => { - 'data' => { - 'guid' => space.guid - } - }, - 'current_droplet' => { - 'data' => { - 'guid' => droplet.guid - } - } - }, - 'links' => { - 'self' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}" }, - 'processes' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/processes" }, - 'packages' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/packages" }, - 'environment_variables' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/environment_variables" }, - 'space' => { 'href' => "#{link_prefix}/v3/spaces/#{space.guid}" }, - 'current_droplet' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/droplets/current" }, - 'droplets' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/droplets" }, - 'tasks' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/tasks" }, - 'start' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/start", 'method' => 'POST' }, - 'stop' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/stop", 'method' => 'POST' }, - 'clear_buildpack_cache' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/clear_buildpack_cache", 'method' => 'POST' }, - 'revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/revisions" }, - 'deployed_revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/revisions/deployed" }, - 'features' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/features" } - } - } - end - - let(:expected_codes_and_responses) do - h = Hash.new({ code: 403, errors: CF_NOT_AUTHORIZED }.freeze) - h['no_role'] = { code: 404 } - h['org_auditor'] = { code: 404 } - h['org_billing_manager'] = { code: 404 } - h['admin'] = { - code: 200, - response_object: app_start_response_object - } - h['space_supporter'] = { - code: 200, - response_object: app_start_response_object - } - h['space_developer'] = { - code: 200, - response_object: app_start_response_object - } - h - end - - before do - space.organization.add_user(user) - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - - context 'when organization is suspended' do - let(:expected_codes_and_responses) do - h = super() - %w[space_supporter space_developer].each { |r| h[r] = { code: 403, errors: CF_ORG_SUSPENDED } } - h - end - - before do - org.update(status: VCAP::CloudController::Organization::SUSPENDED) - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - - describe 'limiting the application log rates' do - let(:log_rate_limit) { -1 } - let(:space_log_rate_limit) { -1 } - let(:org_log_rate_limit) { -1 } - let(:org_quota_definition) { VCAP::CloudController::QuotaDefinition.make(log_rate_limit: org_log_rate_limit) } - let(:org) { VCAP::CloudController::Organization.make(quota_definition: org_quota_definition) } - let(:space_quota_definition) { VCAP::CloudController::SpaceQuotaDefinition.make(organization: org, log_rate_limit: space_log_rate_limit) } - let(:space) { VCAP::CloudController::Space.make(organization: org, space_quota_definition: space_quota_definition) } - let!(:process_model) { VCAP::CloudController::ProcessModel.make(app: app_model, log_rate_limit: log_rate_limit) } - let(:app_model) do - VCAP::CloudController::AppModel.make( - :buildpack, - name: 'app-name', - space: space, - desired_state: 'STOPPED' - ) - end - let(:droplet) { VCAP::CloudController::DropletModel.make(app: app_model, process_types: { web: 'webby' }) } - - before do - app_model.update(droplet_guid: droplet.guid) - end - - describe 'space quotas' do - context 'when both the space and the app do not specify a log rate limit' do - let(:log_rate_limit) { -1 } - let(:space_log_rate_limit) { -1 } - - it 'starts the app successfully' do - post "/v3/apps/#{app_model.guid}/actions/start", nil, admin_header - - expect(last_response.status).to eq(200) - end - end - - context "when the app fits in the space's log rate limit" do - let(:log_rate_limit) { 199 } - let(:space_log_rate_limit) { 200 } - - it 'starts the app successfully' do - post "/v3/apps/#{app_model.guid}/actions/start", nil, admin_header - - expect(last_response.status).to eq(200) - end - end - - context "when the app's log rate limit is unspecified, but the space specifies a log rate limit" do - let(:log_rate_limit) { -1 } - let(:space_log_rate_limit) { 200 } - - it 'fails to start the app' do - post "/v3/apps/#{app_model.guid}/actions/start", nil, admin_header - - expect(last_response.status).to eq(422) - expect(last_response).to have_error_message("log_rate_limit cannot be unlimited in space '#{space.name}'.") - end - end - - context "when the app's log rate limit is larger than the limit specified by the space" do - let(:log_rate_limit) { 201 } - let(:space_log_rate_limit) { 200 } - - it 'fails to start the app' do - post "/v3/apps/#{app_model.guid}/actions/start", nil, admin_header - - expect(last_response.status).to eq(422) - expect(last_response).to have_error_message('log_rate_limit exceeds space log rate quota') - end - end - - context "when the space's quota is more strict that the org's quota, the space quota controls" do - let(:log_rate_limit) { 201 } - let(:space_log_rate_limit) { 200 } - let(:org_log_rate_limit) { 201 } - - it 'fails to start the app' do - post "/v3/apps/#{app_model.guid}/actions/start", nil, admin_header - - expect(last_response.status).to eq(422) - expect(last_response).to have_error_message('log_rate_limit exceeds space log rate quota') - end - end - end - - describe 'organization quotas' do - context 'when both the org and the app do not specify a log rate limit' do - let(:log_rate_limit) { -1 } - let(:org_log_rate_limit) { -1 } - - it 'starts the app successfully' do - post "/v3/apps/#{app_model.guid}/actions/start", nil, admin_header - - expect(last_response.status).to eq(200) - end - end - - context "when the app fits in the org's log rate limit" do - let(:log_rate_limit) { 199 } - let(:org_log_rate_limit) { 200 } - - it 'starts the app successfully' do - post "/v3/apps/#{app_model.guid}/actions/start", nil, admin_header - - expect(last_response.status).to eq(200) - end - end - - context "when the app's log rate limit is unspecified, but the org specifies a log rate limit" do - let(:log_rate_limit) { -1 } - let(:org_log_rate_limit) { 200 } - - it 'fails to start the app' do - post "/v3/apps/#{app_model.guid}/actions/start", nil, admin_header - - expect(last_response.status).to eq(422) - expect(last_response).to have_error_message("log_rate_limit cannot be unlimited in organization '#{org.name}'.") - end - end - - context "when the app's log rate limit is larger than the limit specified by the org" do - let(:log_rate_limit) { 201 } - let(:org_log_rate_limit) { 200 } - - it 'fails to start the app' do - post "/v3/apps/#{app_model.guid}/actions/start", nil, admin_header - - expect(last_response.status).to eq(422) - expect(last_response).to have_error_message('log_rate_limit exceeds organization log rate quota') - end - end - - context "when the org's quota is more strict that the space's quota, the org quota controls" do - let(:log_rate_limit) { 201 } - let(:space_log_rate_limit) { 202 } - let(:org_log_rate_limit) { 200 } - - it 'fails to start the app' do - post "/v3/apps/#{app_model.guid}/actions/start", nil, admin_header - - expect(last_response.status).to eq(422) - expect(last_response).to have_error_message('log_rate_limit exceeds organization log rate quota') - end - end - end - end - end - - context 'events' do - before do - space.organization.add_user(user) - space.add_developer(user) - end - - it 'issues the required events when the app starts' do - post "/v3/apps/#{app_model.guid}/actions/start", nil, user_header - - event = VCAP::CloudController::Event.last - expect(event.values).to include({ - type: 'audit.app.start', - actee: app_model.guid, - actee_type: 'app', - actee_name: 'app-name', - actor: user.guid, - actor_type: 'user', - actor_name: user_email, - actor_username: user_name, - space_guid: space.guid, - organization_guid: space.organization.guid - }) - end - end - - context 'telemetry' do - before do - space.organization.add_user(user) - space.add_developer(user) - end - - it 'logs the required fields when the app starts' do - Timecop.freeze do - expected_json = { - 'telemetry-source' => 'cloud_controller_ng', - 'telemetry-time' => Time.now.to_datetime.rfc3339, - 'start-app' => { - 'api-version' => 'v3', - 'app-id' => OpenSSL::Digest::SHA256.hexdigest(app_model.guid), - 'user-id' => OpenSSL::Digest::SHA256.hexdigest(user.guid) - } - } - expect_any_instance_of(ActiveSupport::Logger).to receive(:info).with(Oj.dump(expected_json)) - post "/v3/apps/#{app_model.guid}/actions/start", nil, user_header - - expect(last_response.status).to eq(200), last_response.body - end - end - end - end - - describe 'when there is a new desired droplet and revision feature is turned on' do - let(:droplet) do - VCAP::CloudController::DropletModel.make( - app: app_model, - process_types: { web: 'rackup' }, - state: VCAP::CloudController::DropletModel::STAGED_STATE, - package: VCAP::CloudController::PackageModel.make - ) - end - - before do - space.organization.add_user(user) - space.add_developer(user) - app_model.update(revisions_enabled: true) - end - - it 'creates a new revision' do - expect do - patch "/v3/apps/#{app_model.guid}/relationships/current_droplet", { data: { guid: droplet.guid } }.to_json, user_header - expect(last_response.status).to eq(200) - end.not_to(change(VCAP::CloudController::RevisionModel, :count)) - - expect do - post "/v3/apps/#{app_model.guid}/actions/start", nil, user_header - expect(last_response.status).to eq(200), last_response.body - end.to change(VCAP::CloudController::RevisionModel, :count).by(1) - end - end - end - - describe 'POST /v3/apps/:guid/actions/stop' do - let(:stack) { VCAP::CloudController::Stack.make(name: 'stack-name') } - let(:app_model) do - VCAP::CloudController::AppModel.make( - :buildpack, - name: 'app-name', - space: space, - desired_state: 'STARTED' - ) - end - let!(:droplet) do - VCAP::CloudController::DropletModel.make(:buildpack, - app: app_model, - state: VCAP::CloudController::DropletModel::STAGED_STATE) - end - - before do - app_model.lifecycle_data.buildpacks = ['http://example.com/git'] - app_model.lifecycle_data.stack = stack.name - app_model.lifecycle_data.save - app_model.droplet = droplet - app_model.save - end - - context 'stopping an app' do - let(:api_call) { ->(user_headers) { post "/v3/apps/#{app_model.guid}/actions/stop", nil, user_headers } } - let(:app_stop_response_object) do - { - 'name' => 'app-name', - 'guid' => app_model.guid, - 'state' => 'STOPPED', - 'created_at' => iso8601, - 'updated_at' => iso8601, - 'metadata' => { 'labels' => {}, 'annotations' => {} }, - 'lifecycle' => { - 'type' => 'buildpack', - 'data' => { - 'buildpacks' => ['http://example.com/git'], - 'stack' => 'stack-name' - } - }, - 'relationships' => { - 'space' => { - 'data' => { - 'guid' => space.guid - } - }, - 'current_droplet' => { - 'data' => { - 'guid' => droplet.guid - } - } - }, - 'links' => { - 'self' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}" }, - 'processes' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/processes" }, - 'packages' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/packages" }, - 'environment_variables' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/environment_variables" }, - 'space' => { 'href' => "#{link_prefix}/v3/spaces/#{space.guid}" }, - 'current_droplet' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/droplets/current" }, - 'droplets' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/droplets" }, - 'tasks' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/tasks" }, - 'start' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/start", 'method' => 'POST' }, - 'stop' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/stop", 'method' => 'POST' }, - 'clear_buildpack_cache' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/clear_buildpack_cache", 'method' => 'POST' }, - 'revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/revisions" }, - 'deployed_revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/revisions/deployed" }, - 'features' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/features" } - } - } - end - - let(:expected_codes_and_responses) do - h = Hash.new({ code: 403, errors: CF_NOT_AUTHORIZED }.freeze) - h['no_role'] = { code: 404 } - h['org_auditor'] = { code: 404 } - h['org_billing_manager'] = { code: 404 } - h['admin'] = { - code: 200, - response_object: app_stop_response_object - } - h['space_supporter'] = { - code: 200, - response_object: app_stop_response_object - } - h['space_developer'] = { - code: 200, - response_object: app_stop_response_object - } - h - end - - before do - space.organization.add_user(user) - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - - context 'when organization is suspended' do - let(:expected_codes_and_responses) do - h = super() - %w[space_supporter space_developer].each { |r| h[r] = { code: 403, errors: CF_ORG_SUSPENDED } } - h - end - - before do - org.update(status: VCAP::CloudController::Organization::SUSPENDED) - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - end - - context 'events' do - before do - space.organization.add_user(user) - space.add_developer(user) - end - - it 'issues the required events when the app stops' do - post "/v3/apps/#{app_model.guid}/actions/stop", nil, user_header - - event = VCAP::CloudController::Event.last - expect(event.values).to include({ - type: 'audit.app.stop', - actee: app_model.guid, - actee_type: 'app', - actee_name: 'app-name', - actor: user.guid, - actor_type: 'user', - actor_name: user_email, - actor_username: user_name, - space_guid: space.guid, - organization_guid: space.organization.guid - }) - end - end - - context 'telemetry' do - before do - space.organization.add_user(user) - space.add_developer(user) - end - - it 'logs the required fields when the app stops' do - Timecop.freeze do - expected_json = { - 'telemetry-source' => 'cloud_controller_ng', - 'telemetry-time' => Time.now.to_datetime.rfc3339, - 'stop-app' => { - 'api-version' => 'v3', - 'app-id' => OpenSSL::Digest::SHA256.hexdigest(app_model.guid), - 'user-id' => OpenSSL::Digest::SHA256.hexdigest(user.guid) - } - } - expect_any_instance_of(ActiveSupport::Logger).to receive(:info).with(Oj.dump(expected_json)) - - post "/v3/apps/#{app_model.guid}/actions/stop", nil, user_header - - expect(last_response.status).to eq(200), last_response.body - end - end - end - end - - describe 'POST /v3/apps/:guid/actions/restart' do - let(:stack) { VCAP::CloudController::Stack.make(name: 'stack-name') } - let(:app_model) do - VCAP::CloudController::AppModel.make( - :buildpack, - name: 'app-name', - space: space, - desired_state: 'STARTED' - ) - end - - context 'app lifecycle is buildpack' do - let!(:droplet) do - VCAP::CloudController::DropletModel.make( - :buildpack, - app: app_model, - state: VCAP::CloudController::DropletModel::STAGED_STATE - ) - end - - before do - app_model.lifecycle_data.buildpacks = ['http://example.com/git'] - app_model.lifecycle_data.stack = stack.name - app_model.lifecycle_data.save - app_model.droplet = droplet - app_model.save - end - - context 'restarting an app' do - let(:api_call) { ->(user_headers) { post "/v3/apps/#{app_model.guid}/actions/restart", nil, user_headers } } - let(:app_restart_response_object) do - { - 'name' => 'app-name', - 'guid' => app_model.guid, - 'state' => 'STARTED', - 'created_at' => iso8601, - 'updated_at' => iso8601, - 'metadata' => { 'labels' => {}, 'annotations' => {} }, - 'lifecycle' => { - 'type' => 'buildpack', - 'data' => { - 'buildpacks' => ['http://example.com/git'], - 'stack' => 'stack-name' - } - }, - 'relationships' => { - 'space' => { - 'data' => { - 'guid' => space.guid - } - }, - 'current_droplet' => { - 'data' => { - 'guid' => droplet.guid - } - } - }, - 'links' => { - 'self' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}" }, - 'processes' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/processes" }, - 'packages' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/packages" }, - 'environment_variables' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/environment_variables" }, - 'space' => { 'href' => "#{link_prefix}/v3/spaces/#{space.guid}" }, - 'current_droplet' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/droplets/current" }, - 'droplets' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/droplets" }, - 'tasks' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/tasks" }, - 'start' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/start", 'method' => 'POST' }, - 'stop' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/stop", 'method' => 'POST' }, - 'clear_buildpack_cache' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/clear_buildpack_cache", 'method' => 'POST' }, - 'revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/revisions" }, - 'deployed_revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/revisions/deployed" }, - 'features' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/features" } - } - } - end - - let(:expected_codes_and_responses) do - h = Hash.new({ code: 403, errors: CF_NOT_AUTHORIZED }.freeze) - h['no_role'] = { code: 404 } - h['org_auditor'] = { code: 404 } - h['org_billing_manager'] = { code: 404 } - h['admin'] = { - code: 200, - response_object: app_restart_response_object - } - h['space_supporter'] = { - code: 200, - response_object: app_restart_response_object - } - h['space_developer'] = { - code: 200, - response_object: app_restart_response_object - } - h - end - - before do - space.organization.add_user(user) - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - - context 'when organization is suspended' do - let(:expected_codes_and_responses) do - h = super() - %w[space_supporter space_developer].each { |r| h[r] = { code: 403, errors: CF_ORG_SUSPENDED } } - h - end - - before do - org.update(status: VCAP::CloudController::Organization::SUSPENDED) - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - end - - context 'telemetry' do - before do - space.organization.add_user(user) - space.add_developer(user) - end - - it 'logs the required fields when the app is restarted' do - Timecop.freeze do - expected_json = { - 'telemetry-source' => 'cloud_controller_ng', - 'telemetry-time' => Time.now.to_datetime.rfc3339, - 'restart-app' => { - 'api-version' => 'v3', - 'app-id' => OpenSSL::Digest::SHA256.hexdigest(app_model.guid), - 'user-id' => OpenSSL::Digest::SHA256.hexdigest(user.guid) - } - } - expect_any_instance_of(ActiveSupport::Logger).to receive(:info).with(Oj.dump(expected_json)) - - post "/v3/apps/#{app_model.guid}/actions/restart", nil, user_header - - expect(last_response.status).to eq(200), last_response.body - end - end - end - end - end -end diff --git a/spec/request/apps/builds_and_ssh_spec.rb b/spec/request/apps/builds_and_ssh_spec.rb deleted file mode 100644 index 9fe458d90e1..00000000000 --- a/spec/request/apps/builds_and_ssh_spec.rb +++ /dev/null @@ -1,222 +0,0 @@ -require 'spec_helper' -require 'actions/process_create_from_app_droplet' -require 'request_spec_shared_examples' -require_relative 'shared_context' - -# Split from spec/request/apps_spec.rb for better test parallelization - -RSpec.describe 'Apps' do - include_context 'apps request spec' - - describe 'GET /v3/apps/:guid/builds' do - let(:app_model) { VCAP::CloudController::AppModel.make(space: space, name: 'my-app') } - let(:build) do - VCAP::CloudController::BuildModel.make( - package: package, - app: app_model, - staging_memory_in_mb: 123, - staging_disk_in_mb: 456, - staging_log_rate_limit: 789, - created_by_user_name: 'bob the builder', - created_by_user_guid: user.guid, - created_by_user_email: 'bob@loblaw.com' - ) - end - let!(:second_build) do - VCAP::CloudController::BuildModel.make( - package: package, - app: app_model, - staging_memory_in_mb: 123, - staging_disk_in_mb: 456, - staging_log_rate_limit: 789, - created_at: build.created_at - 1.day, - created_by_user_name: 'bob the builder', - created_by_user_guid: user.guid, - created_by_user_email: 'bob@loblaw.com' - ) - end - let(:package) { VCAP::CloudController::PackageModel.make(app_guid: app_model.guid) } - let(:droplet) do - VCAP::CloudController::DropletModel.make( - state: VCAP::CloudController::DropletModel::STAGED_STATE, - package_guid: package.guid, - build: build - ) - end - let(:second_droplet) do - VCAP::CloudController::DropletModel.make( - state: VCAP::CloudController::DropletModel::STAGED_STATE, - package_guid: package.guid, - build: second_build - ) - end - let(:body) do - { - lifecycle: { - type: 'buildpack', - data: { - buildpacks: ['http://github.com/myorg/awesome-buildpack'], - stack: 'cflinuxfs4' - } - } - } - end - - describe 'permissions' do - let(:api_call) do - ->(headers) { get "/v3/apps/#{app_model.guid}/builds", nil, headers } - end - let(:expected_codes_and_responses) do - h = Hash.new({ code: 200, response_guids: [build.guid, second_build.guid] }.freeze) - h['org_auditor'] = { code: 404 } - h['org_billing_manager'] = { code: 404 } - h['no_role'] = { code: 404 } - h - end - - it_behaves_like 'permissions for list endpoint', ALL_PERMISSIONS - end - - describe 'as a developer' do - let(:staging_message) { VCAP::CloudController::BuildCreateMessage.new(body) } - let(:per_page) { 2 } - let(:order_by) { '-created_at' } - - before do - space.organization.add_user(user) - space.add_developer(user) - VCAP::CloudController::BuildpackLifecycle.new(package, staging_message).create_lifecycle_data_model(build) - VCAP::CloudController::BuildpackLifecycle.new(package, staging_message).create_lifecycle_data_model(second_build) - build.update(state: droplet.state, error_description: droplet.error_description) - second_build.update(state: second_droplet.state, error_description: second_droplet.error_description) - end - - it 'lists the builds for app' do - get "v3/apps/#{app_model.guid}/builds?order_by=#{order_by}&per_page=#{per_page}", nil, user_header - - parsed_response = Oj.load(last_response.body) - - expect(last_response.status).to eq(200) - expect(parsed_response['resources']).to include(hash_including('guid' => build.guid)) - expect(parsed_response['resources']).to include(hash_including('guid' => second_build.guid)) - expect(parsed_response).to be_a_response_like({ - 'pagination' => { - 'total_results' => 2, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/builds?order_by=#{order_by}&page=1&per_page=2" }, - 'last' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/builds?order_by=#{order_by}&page=1&per_page=2" }, - 'next' => nil, - 'previous' => nil - }, - 'resources' => [ - { - 'guid' => build.guid, - 'created_at' => iso8601, - 'updated_at' => iso8601, - 'state' => 'STAGED', - 'error' => nil, - 'staging_memory_in_mb' => 123, - 'staging_disk_in_mb' => 456, - 'staging_log_rate_limit_bytes_per_second' => 789, - 'lifecycle' => { - 'type' => 'buildpack', - 'data' => { - 'buildpacks' => ['http://github.com/myorg/awesome-buildpack'], - 'stack' => 'cflinuxfs4' - } - }, - 'package' => { 'guid' => package.guid }, - 'droplet' => { - 'guid' => droplet.guid - }, - 'relationships' => { 'app' => { 'data' => { 'guid' => app_model.guid } } }, - 'metadata' => { 'labels' => {}, 'annotations' => {} }, - 'links' => { - 'self' => { 'href' => "#{link_prefix}/v3/builds/#{build.guid}" }, - 'app' => { 'href' => "#{link_prefix}/v3/apps/#{package.app.guid}" }, - 'droplet' => { 'href' => "#{link_prefix}/v3/droplets/#{droplet.guid}" } - }, - 'created_by' => { 'guid' => user.guid, 'name' => 'bob the builder', 'email' => 'bob@loblaw.com' } - }, - { - 'guid' => second_build.guid, - 'created_at' => iso8601, - 'updated_at' => iso8601, - 'state' => 'STAGED', - 'error' => nil, - 'staging_memory_in_mb' => 123, - 'staging_disk_in_mb' => 456, - 'staging_log_rate_limit_bytes_per_second' => 789, - 'lifecycle' => { - 'type' => 'buildpack', - 'data' => { - 'buildpacks' => ['http://github.com/myorg/awesome-buildpack'], - 'stack' => 'cflinuxfs4' - } - }, - 'package' => { 'guid' => package.guid }, - 'droplet' => { - 'guid' => second_droplet.guid - }, - 'relationships' => { 'app' => { 'data' => { 'guid' => app_model.guid } } }, - 'metadata' => { 'labels' => {}, 'annotations' => {} }, - 'links' => { - 'self' => { 'href' => "#{link_prefix}/v3/builds/#{second_build.guid}" }, - 'app' => { 'href' => "#{link_prefix}/v3/apps/#{package.app.guid}" }, - 'droplet' => { 'href' => "#{link_prefix}/v3/droplets/#{second_droplet.guid}" } - }, - 'created_by' => { 'guid' => user.guid, 'name' => 'bob the builder', 'email' => 'bob@loblaw.com' } - } - ] - }) - end - - it_behaves_like 'list_endpoint_with_common_filters' do - let(:resource_klass) { VCAP::CloudController::BuildModel } - let(:additional_resource_params) { { app: app_model } } - let(:api_call) do - ->(headers, filters) { get "/v3/apps/#{app_model.guid}/builds?#{filters}", nil, headers } - end - let(:headers) { admin_header } - end - - it 'filters on label_selector' do - VCAP::CloudController::BuildLabelModel.make(key_name: 'fruit', value: 'strawberry', build: build) - - get "/v3/apps/#{app_model.guid}/builds?label_selector=fruit=strawberry", {}, user_header - - expect(last_response.status).to eq(200) - expect(parsed_response['resources'].count).to eq(1) - expect(parsed_response['resources'][0]['guid']).to eq(build.guid) - end - end - end - - describe 'GET /v3/apps/:guid/ssh_enabled' do - before do - space.organization.add_user(user) - end - - context 'when getting an apps ssh_enabled value' do - let(:api_call) { ->(user_headers) { get "/v3/apps/#{app_model.guid}/ssh_enabled", nil, user_headers } } - let!(:app_model) do - VCAP::CloudController::AppModel.make( - :buildpack, - name: 'my_app', - guid: 'app1_guid', - space: space - ) - end - - let(:expected_codes_and_responses) do - h = Hash.new({ code: 200 }.freeze) - h['org_auditor'] = { code: 404 } - h['org_billing_manager'] = { code: 404 } - h['no_role'] = { code: 404 } - h - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - end -end diff --git a/spec/request/apps/create_spec.rb b/spec/request/apps/create_spec.rb deleted file mode 100644 index fea0470cd57..00000000000 --- a/spec/request/apps/create_spec.rb +++ /dev/null @@ -1,451 +0,0 @@ -require 'spec_helper' -require 'actions/process_create_from_app_droplet' -require 'request_spec_shared_examples' -require_relative 'shared_context' - -# Split from spec/request/apps_spec.rb for better test parallelization - -RSpec.describe 'Apps' do - include_context 'apps request spec' - - describe 'POST /v3/apps' do - let(:buildpack) { VCAP::CloudController::Buildpack.make(stack: stack.name) } - let(:create_request) do - { - name: 'my_app', - environment_variables: { open: 'source' }, - lifecycle: { - type: 'buildpack', - data: { - stack: buildpack.stack, - buildpacks: [buildpack.name] - } - }, - relationships: { - space: { - data: { - guid: space.guid - } - } - }, - metadata: { - labels: { - 'release' => 'stable', - 'code.cloudfoundry.org/cloud_controller_ng' => 'awesome' - }, - annotations: { - 'description' => 'gud app', - 'dora.capi.land/stuff' => 'real gud stuff' - } - } - } - end - - context 'permissions for creating an app' do - let(:api_call) { ->(user_headers) { post '/v3/apps', create_request.to_json, user_headers } } - let(:app_model_response_object) do - { - guid: UUID_REGEX, - created_at: iso8601, - updated_at: iso8601, - name: 'my_app', - state: 'STOPPED', - lifecycle: { - type: 'buildpack', - data: { buildpacks: [buildpack.name], stack: stack.name } - }, - relationships: { - space: { data: { guid: space.guid } }, - current_droplet: { data: { guid: nil } } - }, - metadata: { - labels: { - 'code.cloudfoundry.org/cloud_controller_ng' => 'awesome', - 'release' => 'stable' - }, - annotations: { - 'dora.capi.land/stuff' => 'real gud stuff', - 'description' => 'gud app' - } - }, - links: { - self: { href: %r{#{link_prefix}/v3/apps/#{UUID_REGEX}} }, - environment_variables: { href: %r{#{link_prefix}/v3/apps/#{UUID_REGEX}/environment_variables} }, - space: { href: %r{#{link_prefix}/v3/spaces/#{space.guid}} }, - processes: { href: %r{#{link_prefix}/v3/apps/#{UUID_REGEX}/processes} }, - packages: { href: %r{#{link_prefix}/v3/apps/#{UUID_REGEX}/packages} }, - current_droplet: { href: %r{#{link_prefix}/v3/apps/#{UUID_REGEX}/droplets/current} }, - droplets: { href: %r{#{link_prefix}/v3/apps/#{UUID_REGEX}/droplets} }, - tasks: { href: %r{#{link_prefix}/v3/apps/#{UUID_REGEX}/tasks} }, - start: { href: %r{#{link_prefix}/v3/apps/#{UUID_REGEX}/actions/start}, method: 'POST' }, - stop: { href: %r{#{link_prefix}/v3/apps/#{UUID_REGEX}/actions/stop}, method: 'POST' }, - clear_buildpack_cache: { href: %r{#{link_prefix}/v3/apps/#{UUID_REGEX}/actions/clear_buildpack_cache}, method: 'POST' }, - revisions: { href: %r{#{link_prefix}/v3/apps/#{UUID_REGEX}/revisions} }, - deployed_revisions: { href: %r{#{link_prefix}/v3/apps/#{UUID_REGEX}/revisions/deployed} }, - features: { href: %r{#{link_prefix}/v3/apps/#{UUID_REGEX}/features} } - } - } - end - - let(:expected_codes_and_responses) do - h = Hash.new({ code: 403, errors: CF_NOT_AUTHORIZED }.freeze) - h['org_billing_manager'] = { code: 422 } - h['org_auditor'] = { code: 422 } - h['no_role'] = { code: 422 } - h['admin'] = { - code: 201, - response_object: app_model_response_object - } - h['space_developer'] = { - code: 201, - response_object: app_model_response_object - } - h - end - - before do - space.organization.add_user(user) - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - - context 'when organization is suspended' do - let(:expected_codes_and_responses) do - h = super() - h['space_developer'] = { code: 403, errors: CF_ORG_SUSPENDED } - h - end - - before do - org.update(status: VCAP::CloudController::Organization::SUSPENDED) - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - end - - context 'when the user can create an app' do - before do - space.organization.add_user(user) - space.add_developer(user) - end - - it 'creates an app' do - post '/v3/apps', create_request.to_json, user_header - expect(last_response.status).to eq(201) - - parsed_response = Oj.load(last_response.body) - app_guid = parsed_response['guid'] - - expect(VCAP::CloudController::AppModel.find(guid: app_guid)).to be - expect(parsed_response).to be_a_response_like( - { - 'name' => 'my_app', - 'guid' => app_guid, - 'state' => 'STOPPED', - 'lifecycle' => { - 'type' => 'buildpack', - 'data' => { - 'buildpacks' => [buildpack.name], - 'stack' => stack.name - } - }, - 'relationships' => { - 'space' => { - 'data' => { - 'guid' => space.guid - } - }, - 'current_droplet' => { - 'data' => { - 'guid' => nil - } - } - }, - 'created_at' => iso8601, - 'updated_at' => iso8601, - 'metadata' => { - 'labels' => { - 'release' => 'stable', - 'code.cloudfoundry.org/cloud_controller_ng' => 'awesome' - }, - 'annotations' => { - 'description' => 'gud app', - 'dora.capi.land/stuff' => 'real gud stuff' - } - }, - 'links' => { - 'self' => { 'href' => "#{link_prefix}/v3/apps/#{app_guid}" }, - 'processes' => { 'href' => "#{link_prefix}/v3/apps/#{app_guid}/processes" }, - 'packages' => { 'href' => "#{link_prefix}/v3/apps/#{app_guid}/packages" }, - 'environment_variables' => { 'href' => "#{link_prefix}/v3/apps/#{app_guid}/environment_variables" }, - 'space' => { 'href' => "#{link_prefix}/v3/spaces/#{space.guid}" }, - 'current_droplet' => { 'href' => "#{link_prefix}/v3/apps/#{app_guid}/droplets/current" }, - 'droplets' => { 'href' => "#{link_prefix}/v3/apps/#{app_guid}/droplets" }, - 'tasks' => { 'href' => "#{link_prefix}/v3/apps/#{app_guid}/tasks" }, - 'start' => { 'href' => "#{link_prefix}/v3/apps/#{app_guid}/actions/start", 'method' => 'POST' }, - 'stop' => { 'href' => "#{link_prefix}/v3/apps/#{app_guid}/actions/stop", 'method' => 'POST' }, - 'clear_buildpack_cache' => { 'href' => "#{link_prefix}/v3/apps/#{app_guid}/actions/clear_buildpack_cache", 'method' => 'POST' }, - 'revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_guid}/revisions" }, - 'deployed_revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_guid}/revisions/deployed" }, - 'features' => { 'href' => "#{link_prefix}/v3/apps/#{app_guid}/features" } - } - } - ) - - event = VCAP::CloudController::Event.last - expect(event.values).to include({ - type: 'audit.app.create', - actee: app_guid, - actee_type: 'app', - actee_name: 'my_app', - actor: user.guid, - actor_type: 'user', - actor_name: user_email, - actor_username: user_name, - space_guid: space.guid, - organization_guid: space.organization.guid - }) - end - - it 'creates an empty web process with the same guid as the app (so it is visible on the v2 apps api)' do - post '/v3/apps', create_request.to_json, user_header - expect(last_response.status).to eq(201) - - parsed_response = Oj.load(last_response.body) - app_guid = parsed_response['guid'] - expect(VCAP::CloudController::AppModel.find(guid: app_guid)).not_to be_nil - expect(VCAP::CloudController::ProcessModel.find(guid: app_guid)).not_to be_nil - end - - context 'telemetry' do - let(:logger_spy) { spy('logger') } - - before do - allow(VCAP::CloudController::TelemetryLogger).to receive(:logger).and_return(logger_spy) - end - - it 'logs the required fields when the app is created' do - Timecop.freeze do - post '/v3/apps', create_request.to_json, user_header - - parsed_response = Oj.load(last_response.body) - app_guid = parsed_response['guid'] - - expected_json = { - 'telemetry-source' => 'cloud_controller_ng', - 'telemetry-time' => Time.now.to_datetime.rfc3339, - 'create-app' => { - 'api-version' => 'v3', - 'app-id' => OpenSSL::Digest::SHA256.hexdigest(app_guid), - 'user-id' => OpenSSL::Digest::SHA256.hexdigest(user.guid) - } - }.to_json - expect(logger_spy).to have_received(:info).with(expected_json) - expect(last_response.status).to eq(201), last_response.body - end - end - end - - context 'Docker app' do - before do - VCAP::CloudController::FeatureFlag.make(name: 'diego_docker', enabled: true, error_message: nil) - end - - it 'create a docker app' do - create_request = { - name: 'my_app', - environment_variables: { open: 'source' }, - lifecycle: { - type: 'docker', - data: {} - }, - relationships: { - space: { data: { guid: space.guid } } - } - } - - post '/v3/apps', create_request.to_json, user_header.merge({ 'CONTENT_TYPE' => 'application/json' }) - expect(last_response.status).to eq(201), last_response.body - - created_app = VCAP::CloudController::AppModel.last - expected_response = { - 'name' => 'my_app', - 'guid' => created_app.guid, - 'state' => 'STOPPED', - 'lifecycle' => { - 'type' => 'docker', - 'data' => {} - }, - 'relationships' => { - 'space' => { - 'data' => { - 'guid' => space.guid - } - }, - 'current_droplet' => { - 'data' => { - 'guid' => nil - } - } - }, - 'created_at' => iso8601, - 'updated_at' => iso8601, - 'metadata' => { 'labels' => {}, 'annotations' => {} }, - 'links' => { - 'self' => { 'href' => "#{link_prefix}/v3/apps/#{created_app.guid}" }, - 'processes' => { 'href' => "#{link_prefix}/v3/apps/#{created_app.guid}/processes" }, - 'packages' => { 'href' => "#{link_prefix}/v3/apps/#{created_app.guid}/packages" }, - 'environment_variables' => { 'href' => "#{link_prefix}/v3/apps/#{created_app.guid}/environment_variables" }, - 'space' => { 'href' => "#{link_prefix}/v3/spaces/#{space.guid}" }, - 'current_droplet' => { 'href' => "#{link_prefix}/v3/apps/#{created_app.guid}/droplets/current" }, - 'droplets' => { 'href' => "#{link_prefix}/v3/apps/#{created_app.guid}/droplets" }, - 'tasks' => { 'href' => "#{link_prefix}/v3/apps/#{created_app.guid}/tasks" }, - 'start' => { 'href' => "#{link_prefix}/v3/apps/#{created_app.guid}/actions/start", 'method' => 'POST' }, - 'stop' => { 'href' => "#{link_prefix}/v3/apps/#{created_app.guid}/actions/stop", 'method' => 'POST' }, - 'clear_buildpack_cache' => { 'href' => "#{link_prefix}/v3/apps/#{created_app.guid}/actions/clear_buildpack_cache", 'method' => 'POST' }, - 'revisions' => { 'href' => "#{link_prefix}/v3/apps/#{created_app.guid}/revisions" }, - 'deployed_revisions' => { 'href' => "#{link_prefix}/v3/apps/#{created_app.guid}/revisions/deployed" }, - 'features' => { 'href' => "#{link_prefix}/v3/apps/#{created_app.guid}/features" } - } - } - - parsed_response = Oj.load(last_response.body) - expect(parsed_response).to be_a_response_like(expected_response) - - event = VCAP::CloudController::Event.last - expect(event.values).to include( - type: 'audit.app.create', - actee: created_app.guid, - actee_type: 'app', - actee_name: 'my_app', - actor: user.guid, - actor_type: 'user', - actor_name: user_email, - actor_username: user_name, - space_guid: space.guid, - organization_guid: space.organization.guid - ) - end - end - - context 'cc.default_app_lifecycle' do - let(:create_request) do - { - name: 'my_app', - relationships: { - space: { - data: { - guid: space.guid - } - } - } - } - end - - context 'cc.default_app_lifecycle is set to buildpack' do - before do - TestConfig.override(default_app_lifecycle: 'buildpack') - end - - it 'creates an app with the buildpack lifecycle when none is specified in the request' do - post '/v3/apps', create_request.to_json, user_header - - expect(last_response.status).to eq(201) - parsed_response = Oj.load(last_response.body) - expect(parsed_response['lifecycle']['type']).to eq('buildpack') - end - end - end - end - - context 'stack state validation' do - before do - space.organization.add_user(user) - space.add_developer(user) - end - - context 'when stack is DISABLED' do - let(:disabled_stack) { VCAP::CloudController::Stack.make(name: 'disabled-stack', state: 'DISABLED') } - let(:create_request) do - { - name: 'my_app', - lifecycle: { type: 'buildpack', data: { stack: disabled_stack.name } }, - relationships: { space: { data: { guid: space.guid } } } - } - end - - it 'returns 422 with error message' do - post '/v3/apps', create_request.to_json, user_header - - expect(last_response.status).to eq(422) - expect(parsed_response['errors'].first['detail']).to include('DISABLED') - end - end - - context 'when stack is RESTRICTED' do - let(:restricted_stack) { VCAP::CloudController::Stack.make(name: 'restricted-stack', state: 'RESTRICTED') } - let(:create_request) do - { - name: 'my_app', - lifecycle: { type: 'buildpack', data: { stack: restricted_stack.name } }, - relationships: { space: { data: { guid: space.guid } } } - } - end - - it 'returns 422 with error message for new apps' do - post '/v3/apps', create_request.to_json, user_header - - expect(last_response.status).to eq(422) - expect(parsed_response['errors'].first['detail']).to include('RESTRICTED') - end - end - - context 'when stack is DEPRECATED' do - let(:deprecated_stack) { VCAP::CloudController::Stack.make(name: 'deprecated-stack', state: 'DEPRECATED') } - let(:create_request) do - { - name: 'my_app', - lifecycle: { type: 'buildpack', data: { stack: deprecated_stack.name } }, - relationships: { space: { data: { guid: space.guid } } } - } - end - - it 'creates the app without warnings in response body' do - post '/v3/apps', create_request.to_json, user_header - - expect(last_response.status).to eq(201) - expect(parsed_response).not_to have_key('warnings') - end - - it 'includes warnings in X-Cf-Warnings header' do - post '/v3/apps', create_request.to_json, user_header - - expect(last_response.status).to eq(201) - expect(last_response.headers['X-Cf-Warnings']).to be_present - decoded_warning = CGI.unescape(last_response.headers['X-Cf-Warnings']) - expect(decoded_warning).to include('DEPRECATED') - end - end - - context 'when stack is ACTIVE' do - let(:active_stack) { VCAP::CloudController::Stack.make(name: 'active-stack', state: 'ACTIVE') } - let(:create_request) do - { - name: 'my_app', - lifecycle: { type: 'buildpack', data: { stack: active_stack.name } }, - relationships: { space: { data: { guid: space.guid } } } - } - end - - it 'creates the app without warnings' do - post '/v3/apps', create_request.to_json, user_header - - expect(last_response.status).to eq(201) - expect(parsed_response).not_to have_key('warnings') - expect(last_response.headers['X-Cf-Warnings']).to be_nil - end - end - end - end -end diff --git a/spec/request/apps/delete_and_update_spec.rb b/spec/request/apps/delete_and_update_spec.rb deleted file mode 100644 index 1758648aeeb..00000000000 --- a/spec/request/apps/delete_and_update_spec.rb +++ /dev/null @@ -1,329 +0,0 @@ -require 'spec_helper' -require 'actions/process_create_from_app_droplet' -require 'request_spec_shared_examples' -require_relative 'shared_context' - -# Split from spec/request/apps_spec.rb for better test parallelization - -RSpec.describe 'Apps' do - include_context 'apps request spec' - - describe 'DELETE /v3/apps/guid' do - let!(:app_model) { VCAP::CloudController::AppModel.make(name: 'app_name', space: space) } - let!(:package) { VCAP::CloudController::PackageModel.make(app: app_model) } - let!(:droplet) { VCAP::CloudController::DropletModel.make(package: package, app: app_model) } - let!(:process) { VCAP::CloudController::ProcessModel.make(app: app_model) } - let!(:deployment) { VCAP::CloudController::DeploymentModel.make(app: app_model) } - let(:user_email) { nil } - - it 'deletes an App' do - space.organization.add_user(user) - space.add_developer(user) - delete "/v3/apps/#{app_model.guid}", nil, user_header - - expect(last_response.status).to eq(202) - expect(last_response.headers['Location']).to match(%r{/v3/jobs/#{VCAP::CloudController::PollableJobModel.last.guid}}) - - Delayed::Worker.new.work_off - - expect(app_model).not_to exist - expect(package).not_to exist - expect(droplet).not_to exist - expect(process).not_to exist - expect(deployment).not_to exist - - event = VCAP::CloudController::Event.last(2).first - expect(event.values).to include({ - type: 'audit.app.delete-request', - actee: app_model.guid, - actee_type: 'app', - actee_name: 'app_name', - actor: user.guid, - actor_type: 'user', - actor_name: '', - actor_username: user_name, - space_guid: space.guid, - organization_guid: space.organization.guid - }) - end - - context 'permissions for deleting an app' do - let(:api_call) { ->(user_headers) { delete "/v3/apps/#{app_model.guid}", nil, user_headers } } - let(:expected_codes_and_responses) do - h = Hash.new({ code: 202 }.freeze) - %w[admin_read_only global_auditor org_manager space_auditor space_manager space_supporter].each do |r| - h[r] = { code: 403, errors: CF_NOT_AUTHORIZED } - end - h['org_auditor'] = { code: 404 } - h['org_billing_manager'] = { code: 404 } - h['no_role'] = { code: 404 } - h - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - - context 'when organization is suspended' do - let(:expected_codes_and_responses) do - h = super() - h['space_developer'] = { code: 403, errors: CF_ORG_SUSPENDED } - h - end - - before do - org.update(status: VCAP::CloudController::Organization::SUSPENDED) - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - end - - context 'deleting metadata' do - before do - space.organization.add_user(user) - space.add_developer(user) - end - - it_behaves_like 'resource with metadata' do - let(:resource) { app_model } - let(:api_call) do - -> { delete "/v3/apps/#{resource.guid}", nil, user_header } - end - end - end - end - - describe 'PATCH /v3/apps/:guid' do - let(:app_model) do - VCAP::CloudController::AppModel.make( - :buildpack, - name: 'original_name', - space: space, - environment_variables: { 'ORIGINAL' => 'ENVAR' }, - desired_state: 'STOPPED' - ) - end - let!(:web_process) { VCAP::CloudController::ProcessModel.make(app: app_model, state: VCAP::CloudController::ProcessModel::STOPPED) } - let(:stack) { VCAP::CloudController::Stack.make(name: 'redhat') } - - let(:update_request) do - { - name: 'new-name', - lifecycle: { - type: 'buildpack', - data: { - buildpacks: ['http://gitwheel.org/my-app'], - stack: stack.name - } - }, - metadata: { - labels: { - 'release' => 'stable', - 'code.cloudfoundry.org/cloud_controller_ng' => 'awesome', - 'delete-me' => nil - }, - annotations: { - 'contacts' => 'Bill tel(1111111) email(bill@fixme), Bob tel(222222) pager(3333333#555) email(bob@fixme)', - 'anno1' => 'new-value', - 'please' => nil - } - } - } - end - - let(:expected_response_object) do - { - 'name' => 'new-name', - 'guid' => app_model.guid, - 'state' => 'STOPPED', - 'lifecycle' => { - 'type' => 'buildpack', - 'data' => { - 'buildpacks' => ['http://gitwheel.org/my-app'], - 'stack' => stack.name - } - }, - 'relationships' => { - 'space' => { - 'data' => { - 'guid' => space.guid - } - }, - 'current_droplet' => { - 'data' => { - 'guid' => nil - } - } - }, - 'created_at' => iso8601, - 'updated_at' => iso8601, - 'metadata' => { - 'labels' => { - 'release' => 'stable', - 'code.cloudfoundry.org/cloud_controller_ng' => 'awesome' - }, - 'annotations' => { - 'contacts' => 'Bill tel(1111111) email(bill@fixme), Bob tel(222222) pager(3333333#555) email(bob@fixme)', - 'anno1' => 'new-value' - } - }, - 'links' => { - 'self' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}" }, - 'processes' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/processes" }, - 'packages' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/packages" }, - 'environment_variables' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/environment_variables" }, - 'space' => { 'href' => "#{link_prefix}/v3/spaces/#{space.guid}" }, - 'current_droplet' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/droplets/current" }, - 'droplets' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/droplets" }, - 'tasks' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/tasks" }, - 'start' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/start", 'method' => 'POST' }, - 'stop' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/stop", 'method' => 'POST' }, - 'clear_buildpack_cache' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/clear_buildpack_cache", 'method' => 'POST' }, - 'revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/revisions" }, - 'deployed_revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/revisions/deployed" }, - 'features' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/features" } - } - } - end - - before do - VCAP::CloudController::AppLabelModel.make( - resource_guid: app_model.guid, - key_name: 'delete-me', - value: 'yes' - ) - - VCAP::CloudController::AppAnnotationModel.make( - resource_guid: app_model.guid, - key_name: 'anno1', - value: 'original-value' - ) - - VCAP::CloudController::AppAnnotationModel.make( - resource_guid: app_model.guid, - key_name: 'please', - value: 'delete this' - ) - end - - it 'updates an app' do - space.organization.add_user(user) - space.add_developer(user) - expect_any_instance_of(VCAP::CloudController::Diego::Runner).not_to receive(:update_metric_tags) - patch "/v3/apps/#{app_model.guid}", update_request.to_json, user_header - expect(last_response.status).to eq(200) - - app_model.reload - - parsed_response = Oj.load(last_response.body) - expect(parsed_response).to be_a_response_like(expected_response_object) - - event = VCAP::CloudController::Event.last - expect(event.values).to include({ - type: 'audit.app.update', - actee: app_model.guid, - actee_type: 'app', - actee_name: 'new-name', - actor: user.guid, - actor_type: 'user', - actor_name: user_email, - actor_username: user_name, - space_guid: space.guid, - organization_guid: space.organization.guid - }) - metadata_request = { - 'name' => 'new-name', - 'lifecycle' => { - 'type' => 'buildpack', - 'data' => { - 'buildpacks' => ['http://gitwheel.org/my-app'], - 'stack' => stack.name - } - }, - 'metadata' => { - 'labels' => { - 'release' => 'stable', - 'code.cloudfoundry.org/cloud_controller_ng' => 'awesome', - 'delete-me' => nil - }, - 'annotations' => { - 'contacts' => 'Bill tel(1111111) email(bill@fixme), Bob tel(222222) pager(3333333#555) email(bob@fixme)', - 'anno1' => 'new-value', - 'please' => nil - } - } - } - expect(event.metadata['request']).to eq(metadata_request) - end - - context 'when the app has a process that is started' do - let!(:web_process) { VCAP::CloudController::ProcessModel.make(app: app_model, state: VCAP::CloudController::ProcessModel::STARTED) } - - before do - app_model.desired_state = VCAP::CloudController::ProcessModel::STARTED - end - - it 'notifies diego that an app has been renamed' do - space.organization.add_user(user) - space.add_developer(user) - expect_any_instance_of(VCAP::CloudController::Diego::Runner).to receive(:update_metric_tags) - patch "/v3/apps/#{app_model.guid}", update_request.to_json, user_header - expect(last_response.status).to eq(200) - end - end - - context 'permissions for updating an app' do - let(:api_call) { ->(user_headers) { patch "/v3/apps/#{app_model.guid}", update_request.to_json, user_headers } } - let(:expected_codes_and_responses) do - h = Hash.new({ code: 200, response_object: expected_response_object }.freeze) - %w[admin_read_only global_auditor org_manager space_auditor space_manager space_supporter].each do |r| - h[r] = { code: 403, errors: CF_NOT_AUTHORIZED } - end - h['org_auditor'] = { code: 404 } - h['org_billing_manager'] = { code: 404 } - h['no_role'] = { code: 404 } - h - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - - context 'when organization is suspended' do - let(:expected_codes_and_responses) do - h = super() - h['space_developer'] = { code: 403, errors: CF_ORG_SUSPENDED } - h - end - - before do - org.update(status: VCAP::CloudController::Organization::SUSPENDED) - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - end - - context 'telemetry' do - before do - space.organization.add_user(user) - space.add_developer(user) - end - - it 'logs the required fields when the app gets updated' do - Timecop.freeze do - expected_json = { - 'telemetry-source' => 'cloud_controller_ng', - 'telemetry-time' => Time.now.to_datetime.rfc3339, - 'update-app' => { - 'api-version' => 'v3', - 'app-id' => OpenSSL::Digest::SHA256.hexdigest(app_model.guid), - 'user-id' => OpenSSL::Digest::SHA256.hexdigest(user.guid) - } - } - expect_any_instance_of(ActiveSupport::Logger).to receive(:info).with(Oj.dump(expected_json)) - - patch "/v3/apps/#{app_model.guid}", update_request.to_json, user_header - expect(last_response.status).to eq(200), last_response.body - end - end - end - end -end diff --git a/spec/request/apps/droplet_spec.rb b/spec/request/apps/droplet_spec.rb deleted file mode 100644 index 8983ff34be3..00000000000 --- a/spec/request/apps/droplet_spec.rb +++ /dev/null @@ -1,329 +0,0 @@ -require 'spec_helper' -require 'actions/process_create_from_app_droplet' -require 'request_spec_shared_examples' -require_relative 'shared_context' - -# Split from spec/request/apps_spec.rb for better test parallelization - -RSpec.describe 'Apps' do - include_context 'apps request spec' - - describe 'GET /v3/apps/:guid/relationships/current_droplet' do - let(:api_call) { ->(user_headers) { get "/v3/apps/#{droplet_model.app_guid}/relationships/current_droplet", nil, user_headers } } - let(:app_model) { VCAP::CloudController::AppModel.make(space_guid: space.guid) } - let!(:droplet_model) { VCAP::CloudController::DropletModel.make(app_guid: app_model.guid) } - let(:expected_response) do - { - 'data' => { - 'guid' => droplet_model.guid - }, - 'links' => { - 'self' => { 'href' => "#{link_prefix}/v3/apps/#{droplet_model.app_guid}/relationships/current_droplet" }, - 'related' => { 'href' => "#{link_prefix}/v3/apps/#{droplet_model.app_guid}/droplets/current" } - } - } - end - - let(:expected_codes_and_responses) do - h = Hash.new({ code: 200, response_object: expected_response }.freeze) - h['no_role'] = { code: 404 } - h['org_billing_manager'] = { code: 404 } - h['org_auditor'] = { code: 404 } - h - end - - before do - app_model.droplet_guid = droplet_model.guid - app_model.save - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - - describe 'GET /v3/apps/:guid/droplets/current' do - let(:api_call) { ->(user_headers) { get "/v3/apps/#{droplet_model.app_guid}/droplets/current", nil, user_headers } } - let(:app_model) { VCAP::CloudController::AppModel.make(space_guid: space.guid) } - let(:package_model) { VCAP::CloudController::PackageModel.make(app_guid: app_model.guid) } - let!(:droplet_model) do - VCAP::CloudController::DropletModel.make( - app_guid: app_model.guid, - package_guid: package_model.guid, - buildpack_receipt_buildpack: 'http://buildpack.git.url.com', - error_description: 'example error', - execution_metadata: 'some-data', - droplet_hash: 'shalalala', - sha256_checksum: 'droplet-sha256-checksum', - process_types: { 'web' => 'start-command' } - ) - end - let(:expected_response) do - { - 'guid' => droplet_model.guid, - 'state' => VCAP::CloudController::DropletModel::STAGED_STATE, - 'error' => 'example error', - 'lifecycle' => { - 'type' => 'buildpack', - 'data' => {} - }, - 'checksum' => { 'type' => 'sha256', 'value' => 'droplet-sha256-checksum' }, - 'buildpacks' => [{ 'name' => 'http://buildpack.git.url.com', 'detect_output' => nil, 'buildpack_name' => nil, 'version' => nil }], - 'stack' => 'stack-name', - 'execution_metadata' => 'some-data', - 'process_types' => { 'web' => 'start-command' }, - 'image' => nil, - 'created_at' => iso8601, - 'updated_at' => iso8601, - 'relationships' => { 'app' => { 'data' => { 'guid' => app_model.guid } } }, - 'links' => { - 'self' => { 'href' => "#{link_prefix}/v3/droplets/#{droplet_model.guid}" }, - 'package' => { 'href' => "#{link_prefix}/v3/packages/#{package_model.guid}" }, - 'app' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}" }, - 'download' => { 'href' => "#{link_prefix}/v3/droplets/#{droplet_model.guid}/download" }, - 'assign_current_droplet' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/relationships/current_droplet", 'method' => 'PATCH' } - }, - 'metadata' => { - 'labels' => {}, - 'annotations' => {} - } - } - end - let(:expected_codes_and_responses) do - h = Hash.new({ code: 200, response_object: expected_response }.freeze) - h['no_role'] = { code: 404 } - h['org_billing_manager'] = { code: 404 } - h['org_auditor'] = { code: 404 } - h - end - - before do - droplet_model.buildpack_lifecycle_data.update(buildpacks: ['http://buildpack.git.url.com'], stack: 'stack-name') - app_model.droplet_guid = droplet_model.guid - app_model.save - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - - describe 'PATCH /v3/apps/:guid/relationships/current_droplet' do - let(:stack) { VCAP::CloudController::Stack.make(name: 'stack-name') } - let(:app_model) do - VCAP::CloudController::AppModel.make( - :buildpack, - name: 'my_app', - space: space, - desired_state: 'STOPPED' - ) - end - let(:droplet) do - VCAP::CloudController::DropletModel.make( - :docker, - app: app_model, - process_types: { web: 'rackup' }, - state: VCAP::CloudController::DropletModel::STAGED_STATE, - package: VCAP::CloudController::PackageModel.make - ) - end - let(:request_body) { { data: { guid: droplet.guid } } } - - before do - app_model.lifecycle_data.buildpacks = ['http://example.com/git'] - app_model.lifecycle_data.stack = stack.name - app_model.lifecycle_data.save - end - - context 'assigning the current droplet of the app' do - let(:api_call) { ->(user_headers) { patch "/v3/apps/#{app_model.guid}/relationships/current_droplet", request_body.to_json, user_headers } } - let(:current_droplet_response_object) do - { - 'data' => { - 'guid' => droplet.guid - }, - 'links' => { - 'self' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/relationships/current_droplet" }, - 'related' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/droplets/current" } - } - } - end - - let(:expected_codes_and_responses) do - h = Hash.new({ code: 403, errors: CF_NOT_AUTHORIZED }.freeze) - h['no_role'] = { code: 404 } - h['org_auditor'] = { code: 404 } - h['org_billing_manager'] = { code: 404 } - h['admin'] = { - code: 200, - response_object: current_droplet_response_object - } - h['space_supporter'] = { - code: 200, - response_object: current_droplet_response_object - } - h['space_developer'] = { - code: 200, - response_object: current_droplet_response_object - } - h - end - - before do - space.organization.add_user(user) - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - - context 'when organization is suspended' do - let(:expected_codes_and_responses) do - h = super() - %w[space_supporter space_developer].each { |r| h[r] = { code: 403, errors: CF_ORG_SUSPENDED } } - h - end - - before do - org.update(status: VCAP::CloudController::Organization::SUSPENDED) - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - end - - context 'events' do - before do - space.organization.add_user(user) - space.add_developer(user) - end - - it 'creates audit.app.droplet.mapped event' do - patch "/v3/apps/#{app_model.guid}/relationships/current_droplet", request_body.to_json, user_header - - events = VCAP::CloudController::Event.where(actor: user.guid).all - - droplet_event = events.find { |e| e.type == 'audit.app.droplet.mapped' } - expect(droplet_event.values).to include({ - type: 'audit.app.droplet.mapped', - actee: app_model.guid, - actee_type: 'app', - actee_name: 'my_app', - actor: user.guid, - actor_type: 'user', - actor_name: user_email, - actor_username: user_name, - space_guid: space.guid, - organization_guid: space.organization.guid - }) - expect(droplet_event.metadata).to eq({ 'request' => { 'droplet_guid' => droplet.guid } }) - - expect(app_model.reload.processes.count).to eq(1) - end - - context 'with two process types' do - let(:droplet) do - VCAP::CloudController::DropletModel.make( - app: app_model, - process_types: { web: 'rackup', other: 'cron' }, - state: VCAP::CloudController::DropletModel::STAGED_STATE - ) - end - - it 'creates audit.app.process.create events for each process' do - patch "/v3/apps/#{app_model.guid}/relationships/current_droplet", request_body.to_json, user_header - - expect(last_response.status).to eq(200) - - events = VCAP::CloudController::Event.where(actor: user.guid).all - - expect(app_model.reload.processes.count).to eq(2) - web_process = app_model.processes.find { |i| i.type == 'web' } - other_process = app_model.processes.find { |i| i.type == 'other' } - expect(web_process).to be_present - expect(other_process).to be_present - - web_process_event = events.find { |e| e.metadata['process_guid'] == web_process.guid } - expect(web_process_event.values).to include({ - type: 'audit.app.process.create', - actee: app_model.guid, - actee_type: 'app', - actee_name: 'my_app', - actor: user.guid, - actor_type: 'user', - actor_name: user_email, - actor_username: user_name, - space_guid: space.guid, - organization_guid: space.organization.guid - }) - expect(web_process_event.metadata).to eq({ 'process_guid' => web_process.guid, 'process_type' => 'web' }) - - other_process_event = events.find { |e| e.metadata['process_guid'] == other_process.guid } - expect(other_process_event.values).to include({ - type: 'audit.app.process.create', - actee: app_model.guid, - actee_type: 'app', - actee_name: 'my_app', - actor: user.guid, - actor_type: 'user', - actor_name: user_email, - actor_username: user_name, - space_guid: space.guid, - organization_guid: space.organization.guid - }) - expect(other_process_event.metadata).to eq({ 'process_guid' => other_process.guid, 'process_type' => 'other' }) - end - end - end - - context 'sidecars' do - let(:droplet) do - VCAP::CloudController::DropletModel.make( - :docker, - app: app_model, - process_types: { web: 'rackup' }, - state: VCAP::CloudController::DropletModel::STAGED_STATE, - package: VCAP::CloudController::PackageModel.make, - sidecars: - [ - { - name: 'sidecar_one', - command: 'bundle exec rackup', - process_types: ['web'], - memory: 300 - } - ] - ) - end - - before do - space.organization.add_user(user) - space.add_developer(user) - end - - it 'creates sidecars that were saved on the droplet' do - patch "/v3/apps/#{app_model.guid}/relationships/current_droplet", request_body.to_json, user_header - - expect(last_response.status).to eq(200) - - expect(app_model.reload.processes.count).to eq(1) - expect(app_model.reload.sidecars.count).to eq(1) - end - - it 'logs the create-sidecar event' do - Timecop.freeze do - expected_json = { - 'telemetry-source' => 'cloud_controller_ng', - 'telemetry-time' => Time.now.to_datetime.rfc3339, - 'create-sidecar' => { - 'api-version' => 'v3', - 'origin' => 'buildpack', - 'memory-in-mb' => 300, - 'process-types' => ['web'], - 'app-id' => OpenSSL::Digest::SHA256.hexdigest(app_model.guid) - } - } - expect_any_instance_of(ActiveSupport::Logger).to receive(:info).with(Oj.dump(expected_json)) - - patch "/v3/apps/#{app_model.guid}/relationships/current_droplet", request_body.to_json, user_header - - expect(last_response.status).to eq(200), last_response.body - end - end - end - end -end diff --git a/spec/request/apps/environment_spec.rb b/spec/request/apps/environment_spec.rb deleted file mode 100644 index 6d6527a8027..00000000000 --- a/spec/request/apps/environment_spec.rb +++ /dev/null @@ -1,178 +0,0 @@ -require 'spec_helper' -require 'actions/process_create_from_app_droplet' -require 'request_spec_shared_examples' -require_relative 'shared_context' - -# Split from spec/request/apps_spec.rb for better test parallelization - -RSpec.describe 'Apps' do - include_context 'apps request spec' - - describe 'PATCH /v3/apps/:guid/environment_variables' do - before do - space.organization.add_user(user) - end - - let(:update_request) do - { - var: { - override: 'new-value', - new_key: 'brand-new-value' - } - } - end - let(:app_model) do - VCAP::CloudController::AppModel.make( - name: 'name1', - space: space, - desired_state: 'STOPPED', - environment_variables: { - override: 'original', - preserve: 'keep' - } - ) - end - let(:api_call) { ->(user_headers) { patch "/v3/apps/#{app_model.guid}/environment_variables", update_request.to_json, user_headers } } - let(:app_model_response_object) do - { - 'var' => { - 'override' => 'new-value', - 'new_key' => 'brand-new-value', - 'preserve' => 'keep' - }, - 'links' => { - 'self' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/environment_variables" }, - 'app' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}" } - } - } - end - let(:expected_codes_and_responses) do - h = Hash.new({ code: 404 }.freeze) - %w[global_auditor admin_read_only org_manager space_auditor space_manager].each do |r| - h[r] = { code: 403, errors: CF_NOT_AUTHORIZED } - end - h['admin'] = h['space_developer'] = h['space_supporter'] = { - code: 200, - response_object: app_model_response_object - } - h - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - - context 'when organization is suspended' do - let(:expected_codes_and_responses) do - h = super() - %w[space_developer space_supporter].each { |r| h[r] = { code: 403, errors: CF_ORG_SUSPENDED } } - h - end - - before do - org.update(status: VCAP::CloudController::Organization::SUSPENDED) - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - end - - describe 'GET /v3/apps/:guid/environment_variables' do - let(:app_model) { VCAP::CloudController::AppModel.make(name: 'my_app', space: space, desired_state: 'STARTED', environment_variables: { meep: 'moop' }) } - let(:api_call) { ->(user_headers) { get "/v3/apps/#{app_model.guid}/environment_variables", nil, user_headers } } - let(:app_model_response_object) do - { - var: { - meep: 'moop' - }, - links: { - self: { href: "#{link_prefix}/v3/apps/#{app_model.guid}/environment_variables" }, - app: { href: "#{link_prefix}/v3/apps/#{app_model.guid}" } - } - } - end - - before do - space.organization.add_user(user) - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS do - let(:expected_codes_and_responses) do - h = Hash.new({ code: 404 }.freeze) - h['global_auditor'] = h['org_manager'] = h['space_auditor'] = h['space_manager'] = { code: 403 } - h['admin'] = h['admin_read_only'] = h['space_developer'] = h['space_supporter'] = { - code: 200, - response_object: app_model_response_object - } - h - 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_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS do - let(:expected_codes_and_responses) do - h = Hash.new({ code: 404 }.freeze) - h['global_auditor'] = h['org_manager'] = h['space_auditor'] = h['space_manager'] = h['space_developer'] = h['space_supporter'] = { code: 403 } - h['admin'] = h['admin_read_only'] = { - code: 200, - response_object: app_model_response_object - } - h - end - end - end - - context 'when the encryption_key_label is invalid' do - before do - allow_any_instance_of(ErrorPresenter).to receive(:raise_500?).and_return(false) - end - - it 'fails to decrypt the environment variables and returns a 500 error' do - app_model # ensure that app model is created before run_cipher is mocked to throw an error - allow(VCAP::CloudController::Encryptor).to receive(:run_cipher).and_raise(VCAP::CloudController::Encryptor::EncryptorError) - api_call.call(admin_headers) - - expect(last_response).to have_status_code(500) - expect(parsed_response['errors'].first['detail']).to match(/Error while processing encrypted data/i) - end - end - end - - describe 'GET /v3/apps/:guid/permissions' do - let(:org) { VCAP::CloudController::Organization.make } - let(:space) { VCAP::CloudController::Space.make(organization: org) } - let(:app_model) { VCAP::CloudController::AppModel.make(name: 'name1', space: space, desired_state: 'STOPPED') } - let(:api_call) { ->(user_headers) { get "/v3/apps/#{app_model.guid}/permissions", nil, user_headers } } - - let(:read_all_response) do - { - read_basic_data: true, - read_sensitive_data: true - } - end - - let(:read_basic_response) do - { - read_basic_data: true, - read_sensitive_data: false - } - end - - let(:expected_codes_and_responses) do - h = Hash.new({ code: 404 }.freeze) - h['admin'] = { code: 200, response_object: read_all_response } - h['admin_read_only'] = { code: 200, response_object: read_all_response } - h['global_auditor'] = { code: 200, response_object: read_basic_response } - h['org_manager'] = { code: 200, response_object: read_basic_response } - h['space_manager'] = { code: 200, response_object: read_basic_response } - h['space_auditor'] = { code: 200, response_object: read_basic_response } - h['space_developer'] = { code: 200, response_object: read_all_response } - h['space_supporter'] = { code: 200, response_object: read_basic_response } - h - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end -end diff --git a/spec/request/apps/list_spec.rb b/spec/request/apps/list_spec.rb deleted file mode 100644 index dc20f00fe14..00000000000 --- a/spec/request/apps/list_spec.rb +++ /dev/null @@ -1,940 +0,0 @@ -require 'spec_helper' -require 'actions/process_create_from_app_droplet' -require 'request_spec_shared_examples' -require_relative 'shared_context' - -# Split from spec/request/apps_spec.rb for better test parallelization - -RSpec.describe 'Apps' do - include_context 'apps request spec' - - describe 'GET /v3/apps' do - before do - space.organization.add_user(user) - end - - context 'listing all apps' do - let(:api_call) { ->(user_headers) { get '/v3/apps', nil, user_headers } } - let(:space2) { VCAP::CloudController::Space.make(organization: org) } - let(:buildpack_lifecycle) { VCAP::CloudController::BuildpackLifecycleDataModel.make(stack: 'cool-stack', app: app_model1) } - let(:app_model1) { VCAP::CloudController::AppModel.make(guid: 'app1_guid', name: 'name1', space: space) } - let(:app_model2) { VCAP::CloudController::AppModel.make(guid: 'app2_guid', name: 'name2', space: space2) } - - let(:app_model1_response_object) do - { - guid: app_model1.guid, - created_at: iso8601, - updated_at: iso8601, - name: app_model1.name, - state: 'STOPPED', - lifecycle: { - type: 'buildpack', - data: { buildpacks: [], stack: app_model1.lifecycle_data.stack } - }, - relationships: { - space: { data: { guid: space.guid } }, - current_droplet: { data: { guid: nil } } - }, - metadata: { - labels: {}, - annotations: {} - }, - links: { - self: { href: "#{link_prefix}/v3/apps/app1_guid" }, - environment_variables: { href: "#{link_prefix}/v3/apps/app1_guid/environment_variables" }, - space: { href: "#{link_prefix}/v3/spaces/#{space.guid}" }, - processes: { href: "#{link_prefix}/v3/apps/app1_guid/processes" }, - packages: { href: "#{link_prefix}/v3/apps/app1_guid/packages" }, - current_droplet: { href: "#{link_prefix}/v3/apps/app1_guid/droplets/current" }, - droplets: { href: "#{link_prefix}/v3/apps/app1_guid/droplets" }, - tasks: { href: "#{link_prefix}/v3/apps/app1_guid/tasks" }, - start: { href: "#{link_prefix}/v3/apps/app1_guid/actions/start", method: 'POST' }, - stop: { href: "#{link_prefix}/v3/apps/app1_guid/actions/stop", method: 'POST' }, - clear_buildpack_cache: { href: "#{link_prefix}/v3/apps/app1_guid/actions/clear_buildpack_cache", method: 'POST' }, - revisions: { href: "#{link_prefix}/v3/apps/app1_guid/revisions" }, - deployed_revisions: { href: "#{link_prefix}/v3/apps/app1_guid/revisions/deployed" }, - features: { href: "#{link_prefix}/v3/apps/app1_guid/features" } - } - } - end - - let(:app_model2_response_object) do - { - guid: app_model2.guid, - created_at: iso8601, - updated_at: iso8601, - name: app_model2.name, - state: 'STOPPED', - lifecycle: { - type: 'buildpack', - data: { buildpacks: [], stack: app_model2.lifecycle_data.stack } - }, - relationships: { - space: { data: { guid: space2.guid } }, - current_droplet: { data: { guid: nil } } - }, - metadata: { - labels: {}, - annotations: {} - }, - links: { - self: { href: "#{link_prefix}/v3/apps/app2_guid" }, - environment_variables: { href: "#{link_prefix}/v3/apps/app2_guid/environment_variables" }, - space: { href: "#{link_prefix}/v3/spaces/#{space2.guid}" }, - processes: { href: "#{link_prefix}/v3/apps/app2_guid/processes" }, - packages: { href: "#{link_prefix}/v3/apps/app2_guid/packages" }, - current_droplet: { href: "#{link_prefix}/v3/apps/app2_guid/droplets/current" }, - droplets: { href: "#{link_prefix}/v3/apps/app2_guid/droplets" }, - tasks: { href: "#{link_prefix}/v3/apps/app2_guid/tasks" }, - start: { href: "#{link_prefix}/v3/apps/app2_guid/actions/start", method: 'POST' }, - stop: { href: "#{link_prefix}/v3/apps/app2_guid/actions/stop", method: 'POST' }, - clear_buildpack_cache: { href: "#{link_prefix}/v3/apps/app2_guid/actions/clear_buildpack_cache", method: 'POST' }, - revisions: { href: "#{link_prefix}/v3/apps/app2_guid/revisions" }, - deployed_revisions: { href: "#{link_prefix}/v3/apps/app2_guid/revisions/deployed" }, - features: { href: "#{link_prefix}/v3/apps/app2_guid/features" } - } - } - end - - let(:expected_codes_and_responses) do - h = Hash.new({ code: 200, response_objects: [app_model1_response_object, app_model2_response_object] }.freeze) - - h['org_auditor'] = { - code: 200, - response_objects: [] - } - - h['org_billing_manager'] = { - code: 200, - response_objects: [] - } - - h['space_manager'] = { - code: 200, - response_objects: [ - app_model1_response_object - ] - } - - h['space_auditor'] = { - code: 200, - response_objects: [ - app_model1_response_object - ] - } - - h['space_developer'] = { - code: 200, - response_objects: [ - app_model1_response_object - ] - } - - h['space_supporter'] = { - code: 200, - response_objects: [ - app_model1_response_object - ] - } - - h['no_role'] = { code: 200, response_objects: [] } - h - end - - it_behaves_like 'permissions for list endpoint', ALL_PERMISSIONS - end - - describe 'query list parameters' do - it_behaves_like 'list query endpoint' do - let(:request) { 'v3/apps' } - - let(:message) { VCAP::CloudController::AppsListMessage } - - let(:params) do - { - page: '2', - per_page: '10', - order_by: 'updated_at', - names: 'foo', - guids: 'foo', - organization_guids: 'foo', - space_guids: 'foo', - stacks: 'cf', - include: 'space', - lifecycle_type: 'buildpack', - label_selector: 'foo,bar', - created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", - updated_ats: { gt: Time.now.utc.iso8601 } - } - end - - let!(:app_model) { VCAP::CloudController::AppModel.make } - end - end - - context 'pagination' do - before do - space.add_developer(user) - end - - it 'returns a paginated list of apps the user has access to' do - buildpack = VCAP::CloudController::Buildpack.make(name: 'bp-name') - stack = VCAP::CloudController::Stack.make(name: 'stack-name') - - app_model1 = VCAP::CloudController::AppModel.make(name: 'name1', space: space, desired_state: 'STOPPED') - app_model1.lifecycle_data.update( - buildpacks: [buildpack.name], - stack: stack.name - ) - - app_model2 = VCAP::CloudController::AppModel.make( - :docker, - name: 'name2', - space: space, - desired_state: 'STARTED' - ) - VCAP::CloudController::AppModel.make(space:) - VCAP::CloudController::AppModel.make - - get '/v3/apps?per_page=2&include=space', nil, user_header - expect(last_response.status).to eq(200) - - parsed_response = Oj.load(last_response.body) - expect(parsed_response).to be_a_response_like( - { - 'pagination' => { - 'total_results' => 3, - 'total_pages' => 2, - 'first' => { 'href' => "#{link_prefix}/v3/apps?include=space&page=1&per_page=2" }, - 'last' => { 'href' => "#{link_prefix}/v3/apps?include=space&page=2&per_page=2" }, - 'next' => { 'href' => "#{link_prefix}/v3/apps?include=space&page=2&per_page=2" }, - 'previous' => nil - }, - 'resources' => [ - { - 'guid' => app_model1.guid, - 'name' => 'name1', - 'state' => 'STOPPED', - 'lifecycle' => { - 'type' => 'buildpack', - 'data' => { - 'buildpacks' => ['bp-name'], - 'stack' => 'stack-name' - } - }, - 'relationships' => { - 'space' => { - 'data' => { - 'guid' => space.guid - } - }, - 'current_droplet' => { - 'data' => { - 'guid' => nil - } - } - }, - 'created_at' => iso8601, - 'updated_at' => iso8601, - 'metadata' => { 'labels' => {}, 'annotations' => {} }, - 'links' => { - 'self' => { 'href' => "#{link_prefix}/v3/apps/#{app_model1.guid}" }, - 'processes' => { 'href' => "#{link_prefix}/v3/apps/#{app_model1.guid}/processes" }, - 'packages' => { 'href' => "#{link_prefix}/v3/apps/#{app_model1.guid}/packages" }, - 'environment_variables' => { 'href' => "#{link_prefix}/v3/apps/#{app_model1.guid}/environment_variables" }, - 'space' => { 'href' => "#{link_prefix}/v3/spaces/#{space.guid}" }, - 'current_droplet' => { 'href' => "#{link_prefix}/v3/apps/#{app_model1.guid}/droplets/current" }, - 'droplets' => { 'href' => "#{link_prefix}/v3/apps/#{app_model1.guid}/droplets" }, - 'tasks' => { 'href' => "#{link_prefix}/v3/apps/#{app_model1.guid}/tasks" }, - 'start' => { 'href' => "#{link_prefix}/v3/apps/#{app_model1.guid}/actions/start", 'method' => 'POST' }, - 'stop' => { 'href' => "#{link_prefix}/v3/apps/#{app_model1.guid}/actions/stop", 'method' => 'POST' }, - 'clear_buildpack_cache' => { 'href' => "#{link_prefix}/v3/apps/#{app_model1.guid}/actions/clear_buildpack_cache", 'method' => 'POST' }, - 'revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model1.guid}/revisions" }, - 'deployed_revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model1.guid}/revisions/deployed" }, - 'features' => { 'href' => "#{link_prefix}/v3/apps/#{app_model1.guid}/features" } - } - }, - { - 'guid' => app_model2.guid, - 'name' => 'name2', - 'state' => 'STARTED', - 'lifecycle' => { - 'type' => 'docker', - 'data' => {} - }, - 'relationships' => { - 'space' => { - 'data' => { - 'guid' => space.guid - } - }, - 'current_droplet' => { - 'data' => { - 'guid' => nil - } - } - }, - 'created_at' => iso8601, - 'updated_at' => iso8601, - 'metadata' => { 'labels' => {}, 'annotations' => {} }, - 'links' => { - 'self' => { 'href' => "#{link_prefix}/v3/apps/#{app_model2.guid}" }, - 'processes' => { 'href' => "#{link_prefix}/v3/apps/#{app_model2.guid}/processes" }, - 'packages' => { 'href' => "#{link_prefix}/v3/apps/#{app_model2.guid}/packages" }, - 'environment_variables' => { 'href' => "#{link_prefix}/v3/apps/#{app_model2.guid}/environment_variables" }, - 'space' => { 'href' => "#{link_prefix}/v3/spaces/#{space.guid}" }, - 'current_droplet' => { 'href' => "#{link_prefix}/v3/apps/#{app_model2.guid}/droplets/current" }, - 'droplets' => { 'href' => "#{link_prefix}/v3/apps/#{app_model2.guid}/droplets" }, - 'tasks' => { 'href' => "#{link_prefix}/v3/apps/#{app_model2.guid}/tasks" }, - 'start' => { 'href' => "#{link_prefix}/v3/apps/#{app_model2.guid}/actions/start", 'method' => 'POST' }, - 'stop' => { 'href' => "#{link_prefix}/v3/apps/#{app_model2.guid}/actions/stop", 'method' => 'POST' }, - 'clear_buildpack_cache' => { 'href' => "#{link_prefix}/v3/apps/#{app_model2.guid}/actions/clear_buildpack_cache", 'method' => 'POST' }, - 'revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model2.guid}/revisions" }, - 'deployed_revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model2.guid}/revisions/deployed" }, - 'features' => { 'href' => "#{link_prefix}/v3/apps/#{app_model2.guid}/features" } - } - } - ], - 'included' => { - 'spaces' => [{ - 'guid' => space.guid, - 'created_at' => iso8601, - 'updated_at' => iso8601, - 'name' => space.name, - 'relationships' => { - 'organization' => { - 'data' => { - 'guid' => space.organization.guid - } - }, - 'quota' => { - 'data' => nil - } - }, - 'metadata' => { - 'labels' => {}, - 'annotations' => {} - }, - 'links' => { - 'self' => { - 'href' => "#{link_prefix}/v3/spaces/#{space.guid}" - }, - 'organization' => { - 'href' => "#{link_prefix}/v3/organizations/#{space.organization.guid}" - }, - 'features' => { 'href' => %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}/features} }, - 'apply_manifest' => { - 'href' => "#{link_prefix}/v3/spaces/#{space.guid}/actions/apply_manifest", - 'method' => 'POST' - } - } - }] - } - } - ) - end - end - - context 'filtering by timestamps' do - before do - VCAP::CloudController::AppModel.plugin :timestamps, update_on_create: false - end - - # .make updates the resource after creating it, over writing our passed in updated_at timestamp - # Therefore we cannot use shared_examples as the updated_at will not be as written - let!(:resource_1) { VCAP::CloudController::AppModel.create(name: '1', created_at: '2020-05-26T18:47:01Z', updated_at: '2020-05-26T18:47:01Z', space: space) } - let!(:resource_2) { VCAP::CloudController::AppModel.create(name: '2', created_at: '2020-05-26T18:47:02Z', updated_at: '2020-05-26T18:47:02Z', space: space) } - let!(:resource_3) { VCAP::CloudController::AppModel.create(name: '3', created_at: '2020-05-26T18:47:03Z', updated_at: '2020-05-26T18:47:03Z', space: space) } - let!(:resource_4) { VCAP::CloudController::AppModel.create(name: '4', created_at: '2020-05-26T18:47:04Z', updated_at: '2020-05-26T18:47:04Z', space: space) } - - after do - VCAP::CloudController::AppModel.plugin :timestamps, update_on_create: true - end - - it 'filters by the created at' do - get "/v3/apps?created_ats[lt]=#{resource_3.created_at.iso8601}", nil, admin_header - - expect(last_response).to have_status_code(200) - expect(parsed_response['resources'].pluck('guid')).to contain_exactly(resource_1.guid, resource_2.guid) - end - - it 'filters ny the updated_at' do - get "/v3/apps?updated_ats[lt]=#{resource_3.updated_at.iso8601}", nil, admin_header - - expect(last_response).to have_status_code(200) - expect(parsed_response['resources'].pluck('guid')).to contain_exactly(resource_1.guid, resource_2.guid) - end - end - - context 'faceted search' do - let(:admin_header) { headers_for(user, scopes: %w[cloud_controller.admin]) } - - it 'filters by guids' do - app_model1 = VCAP::CloudController::AppModel.make(name: 'name1') - VCAP::CloudController::AppModel.make(name: 'name2') - app_model3 = VCAP::CloudController::AppModel.make(name: 'name3') - - get "/v3/apps?guids=#{app_model1.guid}%2C#{app_model3.guid}", nil, admin_header - - expected_pagination = { - 'total_results' => 2, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/apps?guids=#{app_model1.guid}%2C#{app_model3.guid}&page=1&per_page=50" }, - 'last' => { 'href' => "#{link_prefix}/v3/apps?guids=#{app_model1.guid}%2C#{app_model3.guid}&page=1&per_page=50" }, - 'next' => nil, - 'previous' => nil - } - - parsed_response = Oj.load(last_response.body) - - expect(last_response.status).to eq(200) - expect(parsed_response['resources'].pluck('name')).to eq(%w[name1 name3]) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - - it 'filters by names' do - VCAP::CloudController::AppModel.make(name: 'name1') - VCAP::CloudController::AppModel.make(name: 'name2') - VCAP::CloudController::AppModel.make(name: 'name3') - - get '/v3/apps?names=name1%2Cname2', nil, admin_header - - expected_pagination = { - 'total_results' => 2, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/apps?names=name1%2Cname2&page=1&per_page=50" }, - 'last' => { 'href' => "#{link_prefix}/v3/apps?names=name1%2Cname2&page=1&per_page=50" }, - 'next' => nil, - 'previous' => nil - } - - parsed_response = Oj.load(last_response.body) - - expect(last_response.status).to eq(200) - expect(parsed_response['resources'].pluck('name')).to eq(%w[name1 name2]) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - - it 'filters by organizations' do - app_model1 = VCAP::CloudController::AppModel.make(name: 'name1') - VCAP::CloudController::AppModel.make(name: 'name2') - app_model3 = VCAP::CloudController::AppModel.make(name: 'name3') - - get "/v3/apps?organization_guids=#{app_model1.organization.guid}%2C#{app_model3.organization.guid}", nil, admin_header - - expected_pagination = { - 'total_results' => 2, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/apps?organization_guids=#{app_model1.organization.guid}%2C#{app_model3.organization.guid}&page=1&per_page=50" }, - 'last' => { 'href' => "#{link_prefix}/v3/apps?organization_guids=#{app_model1.organization.guid}%2C#{app_model3.organization.guid}&page=1&per_page=50" }, - 'next' => nil, - 'previous' => nil - } - - parsed_response = Oj.load(last_response.body) - - expect(last_response.status).to eq(200) - expect(parsed_response['resources'].pluck('name')).to eq(%w[name1 name3]) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - - it 'filters by spaces' do - app_model1 = VCAP::CloudController::AppModel.make(name: 'name1') - VCAP::CloudController::AppModel.make(name: 'name2') - app_model3 = VCAP::CloudController::AppModel.make(name: 'name3') - - get "/v3/apps?space_guids=#{app_model1.space.guid}%2C#{app_model3.space.guid}", nil, admin_header - - expected_pagination = { - 'total_results' => 2, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/apps?page=1&per_page=50&space_guids=#{app_model1.space.guid}%2C#{app_model3.space.guid}" }, - 'last' => { 'href' => "#{link_prefix}/v3/apps?page=1&per_page=50&space_guids=#{app_model1.space.guid}%2C#{app_model3.space.guid}" }, - 'next' => nil, - 'previous' => nil - } - - parsed_response = Oj.load(last_response.body) - - expect(last_response.status).to eq(200) - expect(parsed_response['resources'].pluck('name')).to eq(%w[name1 name3]) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - - it 'filters by stack names' do - app_model1 = VCAP::CloudController::AppModel.make(name: 'name1') - app_model2 = VCAP::CloudController::AppModel.make(name: 'name2') - app_model3 = VCAP::CloudController::AppModel.make(name: 'name3') - - stack2 = VCAP::CloudController::Stack.make(name: 'name2') - stack3 = VCAP::CloudController::Stack.make(name: 'name3') - - app_model1.lifecycle_data.stack = stack2.name - app_model1.lifecycle_data.save - - app_model2.lifecycle_data.stack = stack2.name - app_model2.lifecycle_data.save - - app_model3.lifecycle_data.stack = stack3.name - app_model3.lifecycle_data.save - - get "/v3/apps?stacks=#{stack2.name}", nil, admin_header - - expected_pagination = { - 'total_results' => 2, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/apps?page=1&per_page=50&stacks=#{stack2.name}" }, - 'last' => { 'href' => "#{link_prefix}/v3/apps?page=1&per_page=50&stacks=#{stack2.name}" }, - 'next' => nil, - 'previous' => nil - } - - parsed_response = Oj.load(last_response.body) - - expect(last_response.status).to eq(200) - expect(parsed_response['resources'].pluck('name')).to eq(%w[name1 name2]) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - - it 'filters by null stacks' do - app_model1 = VCAP::CloudController::AppModel.make(name: 'name1') - app_model2 = VCAP::CloudController::AppModel.make(name: 'name2') - app_model3 = VCAP::CloudController::AppModel.make(name: 'name3') - - stack2 = VCAP::CloudController::Stack.make(name: 'name2') - stack3 = VCAP::CloudController::Stack.make(name: 'name3') - - app_model1.lifecycle_data.stack = nil - app_model1.lifecycle_data.save - - app_model2.lifecycle_data.stack = stack2.name - app_model2.lifecycle_data.save - - app_model3.lifecycle_data.stack = stack3.name - app_model3.lifecycle_data.save - - get '/v3/apps?stacks=', nil, admin_header - - expected_pagination = { - 'total_results' => 1, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/apps?page=1&per_page=50&stacks=" }, - 'last' => { 'href' => "#{link_prefix}/v3/apps?page=1&per_page=50&stacks=" }, - 'next' => nil, - 'previous' => nil - } - - parsed_response = Oj.load(last_response.body) - - expect(last_response.status).to eq(200) - expect(parsed_response['resources'].pluck('name')).to eq(['name1']) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - - it 'filters by lifecycle_type' do - VCAP::CloudController::AppModel.make(name: 'name1') - docker_app_model = VCAP::CloudController::AppModel.make(name: 'name2') - VCAP::CloudController::AppModel.make(name: 'name3') - - docker_app_model.buildpack_lifecycle_data = nil - docker_app_model.save - - get '/v3/apps?lifecycle_type=buildpack', nil, admin_header - - expected_pagination = { - 'total_results' => 2, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/apps?lifecycle_type=buildpack&page=1&per_page=50" }, - 'last' => { 'href' => "#{link_prefix}/v3/apps?lifecycle_type=buildpack&page=1&per_page=50" }, - 'next' => nil, - 'previous' => nil - } - - parsed_response = Oj.load(last_response.body) - - expect(last_response.status).to eq(200) - expect(parsed_response['resources'].pluck('name')).to eq(%w[name1 name3]) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - end - - context 'ordering' do - before do - space.add_developer(user) - end - - it 'can order by name' do - VCAP::CloudController::AppModel.make(space: space, name: 'zed') - VCAP::CloudController::AppModel.make(space: space, name: 'alpha') - VCAP::CloudController::AppModel.make(space: space, name: 'gamma') - VCAP::CloudController::AppModel.make(space: space, name: 'delta') - VCAP::CloudController::AppModel.make(space: space, name: 'theta') - - ascending = %w[alpha delta gamma theta zed] - descending = ascending.reverse - - # ASCENDING - get '/v3/apps?order_by=name', nil, user_header - expect(last_response.status).to eq(200) - parsed_response = Oj.load(last_response.body) - app_names = parsed_response['resources'].pluck('name') - expect(app_names).to eq(ascending) - expect(parsed_response['pagination']['first']['href']).to include("order_by=#{CGI.escape('+')}name") - - # DESCENDING - get '/v3/apps?order_by=-name', nil, user_header - expect(last_response.status).to eq(200) - parsed_response = Oj.load(last_response.body) - app_names = parsed_response['resources'].pluck('name') - expect(app_names).to eq(descending) - expect(parsed_response['pagination']['first']['href']).to include('order_by=-name') - end - - it 'can order by state' do - VCAP::CloudController::AppModel.make(space: space, desired_state: 'STARTED') - VCAP::CloudController::AppModel.make(space: space, desired_state: 'STOPPED') - VCAP::CloudController::AppModel.make(space: space, desired_state: 'STARTED') - VCAP::CloudController::AppModel.make(space: space, desired_state: 'STOPPED') - ascending = %w[STARTED STARTED STOPPED STOPPED] - descending = ascending.reverse - - # ASCENDING - get '/v3/apps?order_by=state', nil, user_header - expect(last_response.status).to eq(200) - parsed_response = Oj.load(last_response.body) - app_states = parsed_response['resources'].pluck('state') - expect(app_states).to eq(ascending) - expect(parsed_response['pagination']['first']['href']).to include("order_by=#{CGI.escape('+')}state") - - # DESCENDING - get '/v3/apps?order_by=-state', nil, user_header - expect(last_response.status).to eq(200) - parsed_response = Oj.load(last_response.body) - app_states = parsed_response['resources'].pluck('state') - expect(app_states).to eq(descending) - expect(parsed_response['pagination']['first']['href']).to include('order_by=-state') - end - end - - context 'labels' do - let!(:app1) { VCAP::CloudController::AppModel.make(name: 'name1') } - let!(:app1_label) { VCAP::CloudController::AppLabelModel.make(resource_guid: app1.guid, key_name: 'foo', value: 'bar') } - - let!(:app2) { VCAP::CloudController::AppModel.make(name: 'name2') } - let!(:app2_label) { VCAP::CloudController::AppLabelModel.make(resource_guid: app2.guid, key_name: 'foo', value: 'funky') } - let!(:app2__exclusive_label) { VCAP::CloudController::AppLabelModel.make(resource_guid: app2.guid, key_name: 'santa', value: 'claus') } - - let(:admin_header) { headers_for(user, scopes: %w[cloud_controller.admin]) } - - it 'returns a 200 and the filtered apps for "in" label selector' do - get '/v3/apps?label_selector=foo in (bar)', nil, admin_header - - parsed_response = Oj.load(last_response.body) - - expected_pagination = { - 'total_results' => 1, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/apps?label_selector=foo+in+%28bar%29&page=1&per_page=50" }, - 'last' => { 'href' => "#{link_prefix}/v3/apps?label_selector=foo+in+%28bar%29&page=1&per_page=50" }, - 'next' => nil, - 'previous' => nil - } - - expect(last_response.status).to eq(200) - expect(parsed_response['resources'].pluck('guid')).to contain_exactly(app1.guid) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - - it 'returns a 200 and the filtered apps for "notin" label selector' do - get '/v3/apps?label_selector=foo notin (bar)', nil, admin_header - - parsed_response = Oj.load(last_response.body) - - expected_pagination = { - 'total_results' => 1, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/apps?label_selector=foo+notin+%28bar%29&page=1&per_page=50" }, - 'last' => { 'href' => "#{link_prefix}/v3/apps?label_selector=foo+notin+%28bar%29&page=1&per_page=50" }, - 'next' => nil, - 'previous' => nil - } - - expect(last_response.status).to eq(200) - expect(parsed_response['resources'].pluck('guid')).to contain_exactly(app2.guid) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - - it 'returns a 200 and the filtered apps for "=" label selector' do - get '/v3/apps?label_selector=foo=bar', nil, admin_header - - parsed_response = Oj.load(last_response.body) - - expected_pagination = { - 'total_results' => 1, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/apps?label_selector=foo%3Dbar&page=1&per_page=50" }, - 'last' => { 'href' => "#{link_prefix}/v3/apps?label_selector=foo%3Dbar&page=1&per_page=50" }, - 'next' => nil, - 'previous' => nil - } - - expect(last_response.status).to eq(200) - expect(parsed_response['resources'].pluck('guid')).to contain_exactly(app1.guid) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - - it 'returns a 200 and the filtered apps for "==" label selector' do - get '/v3/apps?label_selector=foo==bar', nil, admin_header - - parsed_response = Oj.load(last_response.body) - - expected_pagination = { - 'total_results' => 1, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/apps?label_selector=foo%3D%3Dbar&page=1&per_page=50" }, - 'last' => { 'href' => "#{link_prefix}/v3/apps?label_selector=foo%3D%3Dbar&page=1&per_page=50" }, - 'next' => nil, - 'previous' => nil - } - - expect(last_response.status).to eq(200) - expect(parsed_response['resources'].pluck('guid')).to contain_exactly(app1.guid) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - - it 'returns a 200 and the filtered apps for "!=" label selector' do - get '/v3/apps?label_selector=foo!=bar', nil, admin_header - - parsed_response = Oj.load(last_response.body) - - expected_pagination = { - 'total_results' => 1, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/apps?label_selector=foo%21%3Dbar&page=1&per_page=50" }, - 'last' => { 'href' => "#{link_prefix}/v3/apps?label_selector=foo%21%3Dbar&page=1&per_page=50" }, - 'next' => nil, - 'previous' => nil - } - - expect(last_response.status).to eq(200) - expect(parsed_response['resources'].pluck('guid')).to contain_exactly(app2.guid) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - - it 'returns a 200 and the filtered apps for "==" label selector' do - get '/v3/apps?label_selector=foo=funky,santa=claus', nil, admin_header - - parsed_response = Oj.load(last_response.body) - - expected_pagination = { - 'total_results' => 1, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/apps?label_selector=foo%3Dfunky%2Csanta%3Dclaus&page=1&per_page=50" }, - 'last' => { 'href' => "#{link_prefix}/v3/apps?label_selector=foo%3Dfunky%2Csanta%3Dclaus&page=1&per_page=50" }, - 'next' => nil, - 'previous' => nil - } - - expect(last_response.status).to eq(200) - expect(parsed_response['resources'].pluck('guid')).to contain_exactly(app2.guid) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - - it 'returns a 200 and the filtered apps for existence label selector' do - get '/v3/apps?label_selector=santa', nil, admin_header - - parsed_response = Oj.load(last_response.body) - - expected_pagination = { - 'total_results' => 1, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/apps?label_selector=santa&page=1&per_page=50" }, - 'last' => { 'href' => "#{link_prefix}/v3/apps?label_selector=santa&page=1&per_page=50" }, - 'next' => nil, - 'previous' => nil - } - - expect(last_response.status).to eq(200) - expect(parsed_response['resources'].pluck('guid')).to contain_exactly(app2.guid) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - - it 'returns a 200 and the filtered apps for non-existence label selector' do - get '/v3/apps?label_selector=!santa', nil, admin_header - - parsed_response = Oj.load(last_response.body) - - expected_pagination = { - 'total_results' => 1, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/apps?label_selector=%21santa&page=1&per_page=50" }, - 'last' => { 'href' => "#{link_prefix}/v3/apps?label_selector=%21santa&page=1&per_page=50" }, - 'next' => nil, - 'previous' => nil - } - - expect(last_response.status).to eq(200) - expect(parsed_response['resources'].pluck('guid')).to contain_exactly(app1.guid) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - end - - context 'labels and existing filters' do - let!(:space1) { VCAP::CloudController::Space.make } - let!(:space2) { VCAP::CloudController::Space.make } - let!(:app1) { VCAP::CloudController::AppModel.make(name: 'name1', space: space1) } - let!(:app2) { VCAP::CloudController::AppModel.make(name: 'name2', space: space2) } - let!(:app3) { VCAP::CloudController::AppModel.make(name: 'name3', space: space2) } - let!(:app1_label1) { VCAP::CloudController::AppLabelModel.make(resource_guid: app1.guid, key_name: 'foo', value: 'funky') } - let!(:app2_label1) { VCAP::CloudController::AppLabelModel.make(resource_guid: app2.guid, key_name: 'foo', value: 'funky') } - let!(:app2_label2) { VCAP::CloudController::AppLabelModel.make(resource_guid: app2.guid, key_name: 'fruit', value: 'strawberry') } - let!(:app3_label1) { VCAP::CloudController::AppLabelModel.make(resource_guid: app3.guid, key_name: 'fruit', value: 'strawberry') } - - let(:admin_header) { headers_for(user, scopes: %w[cloud_controller.admin]) } - - it 'returns a 200 and the correct app when querying with space guid' do - get "/v3/apps?space_guids=#{space2.guid}&label_selector=foo==funky", nil, admin_header - - parsed_response = Oj.load(last_response.body) - - expected_pagination = { - 'total_results' => 1, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/apps?label_selector=foo%3D%3Dfunky&page=1&per_page=50&space_guids=#{space2.guid}" }, - 'last' => { 'href' => "#{link_prefix}/v3/apps?label_selector=foo%3D%3Dfunky&page=1&per_page=50&space_guids=#{space2.guid}" }, - 'next' => nil, - 'previous' => nil - } - - expect(last_response.status).to eq(200) - expect(parsed_response['resources'].pluck('guid')).to contain_exactly(app2.guid) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - - it 'returns a 200 and the correct app when querying with space guid' do - get "/v3/apps?space_guids=#{space2.guid}&label_selector=fruit==strawberry&names=name2", nil, admin_header - - parsed_response = Oj.load(last_response.body) - - expected_pagination = { - 'total_results' => 1, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/apps?label_selector=fruit%3D%3Dstrawberry&names=name2&page=1&per_page=50&space_guids=#{space2.guid}" }, - 'last' => { 'href' => "#{link_prefix}/v3/apps?label_selector=fruit%3D%3Dstrawberry&names=name2&page=1&per_page=50&space_guids=#{space2.guid}" }, - 'next' => nil, - 'previous' => nil - } - - expect(last_response.status).to eq(200) - expect(parsed_response['resources'].pluck('guid')).to contain_exactly(app2.guid) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - end - - context 'including orgs and spaces' do - it 'presents the apps listed with the orgs and spaces included' do - VCAP::CloudController::AppModel.make(:docker, name: 'name1', guid: 'app1-guid', space: space) - - org1 = space.organization - org2 = VCAP::CloudController::Organization.make(name: 'org2', guid: 'org2-guid', created_at: 1.day.ago) - space2 = VCAP::CloudController::Space.make(name: 'space2', guid: 'space2-guid', organization: org2) - - unused_org = VCAP::CloudController::Organization.make(name: 'unused_org', guid: 'unused_org-guid') - - VCAP::CloudController::Space.make(name: 'unused_space', guid: 'unused_space-guid', organization: unused_org) - - VCAP::CloudController::AppModel.make( - :docker, - name: 'name2', - guid: 'app2-guid', - space: space2 - ) - - get '/v3/apps?per_page=2&include=space,space.organization', nil, admin_header - expect(last_response.status).to eq(200) - - parsed_response = Oj.load(last_response.body) - - expect(parsed_response['included']['organizations'][0]).to be_a_response_like({ - 'guid' => org1.guid, - 'created_at' => iso8601, - 'updated_at' => iso8601, - 'name' => org1.name, - 'metadata' => { - 'labels' => {}, - 'annotations' => {} - }, - 'suspended' => false, - 'links' => { - 'self' => { - 'href' => "#{link_prefix}/v3/organizations/#{org1.guid}" - }, - 'default_domain' => { - 'href' => "#{link_prefix}/v3/organizations/#{org1.guid}/domains/default" - }, - 'domains' => { - 'href' => "#{link_prefix}/v3/organizations/#{org1.guid}/domains" - }, - 'quota' => { - 'href' => "#{link_prefix}/v3/organization_quotas/#{org1.quota_definition.guid}" - } - }, - 'relationships' => { 'quota' => { 'data' => { 'guid' => org1.quota_definition.guid } } } - }) - expect(parsed_response['included']['organizations'][1]).to be_a_response_like({ - 'guid' => org2.guid, - 'created_at' => iso8601, - 'updated_at' => iso8601, - 'name' => org2.name, - 'suspended' => false, - 'metadata' => { - 'labels' => {}, - 'annotations' => {} - }, - 'links' => { - 'self' => { - 'href' => "#{link_prefix}/v3/organizations/#{org2.guid}" - }, - 'default_domain' => { - 'href' => "#{link_prefix}/v3/organizations/#{org2.guid}/domains/default" - }, - 'domains' => { - 'href' => "#{link_prefix}/v3/organizations/#{org2.guid}/domains" - }, - 'quota' => { - 'href' => "#{link_prefix}/v3/organization_quotas/#{org2.quota_definition.guid}" - } - }, - 'relationships' => { 'quota' => { 'data' => { 'guid' => org2.quota_definition.guid } } } - }) - end - - it 'flags unsupported includes that contain supported ones' do - get '/v3/apps?per_page=2&include=space.organization,spaceship,borgs,space', nil, admin_header - expect(last_response.status).to eq(400) - end - - it 'does not include spaces if no one asks for them' do - get '/v3/apps', nil, admin_header - parsed_response = Oj.load(last_response.body) - expect(parsed_response).not_to have_key('included') - end - end - - context 'when including orgs' do - before do - VCAP::CloudController::AppModel.make - end - - it 'eagerly loads spaces to efficiently access space.organization_id' do - expect(VCAP::CloudController::IncludeOrganizationDecorator).to receive(:decorate) do |_, resources| - expect(resources).not_to be_empty - resources.each { |r| expect(r.associations).to include(:space) } - end - - get '/v3/apps?include=space.organization', nil, admin_header - expect(last_response).to have_status_code(200) - end - end - end -end diff --git a/spec/request/apps/shared_context.rb b/spec/request/apps/shared_context.rb deleted file mode 100644 index e2d74fc0a4f..00000000000 --- a/spec/request/apps/shared_context.rb +++ /dev/null @@ -1,10 +0,0 @@ -RSpec.shared_context 'apps request spec' do - let(:user) { VCAP::CloudController::User.make } - let(:user_header) { headers_for(user, email: user_email, user_name: user_name) } - let(:admin_header) { admin_headers_for(user) } - let(:org) { VCAP::CloudController::Organization.make(created_at: 3.days.ago) } - let(:space) { VCAP::CloudController::Space.make(organization: org) } - let(:stack) { VCAP::CloudController::Stack.make } - let(:user_email) { Sham.email } - let(:user_name) { 'some-username' } -end diff --git a/spec/request/apps/show_spec.rb b/spec/request/apps/show_spec.rb deleted file mode 100644 index 7c81205f9d2..00000000000 --- a/spec/request/apps/show_spec.rb +++ /dev/null @@ -1,495 +0,0 @@ -require 'spec_helper' -require 'actions/process_create_from_app_droplet' -require 'request_spec_shared_examples' -require_relative 'shared_context' - -# Split from spec/request/apps_spec.rb for better test parallelization - -RSpec.describe 'Apps' do - include_context 'apps request spec' - - describe 'GET /v3/apps/:guid' do - let!(:buildpack) { VCAP::CloudController::Buildpack.make(name: 'bp-name') } - let!(:stack) { VCAP::CloudController::Stack.make(name: 'stack-name') } - let!(:app_model) do - VCAP::CloudController::AppModel.make( - :buildpack, - name: 'my_app', - guid: 'app1_guid', - space: space, - desired_state: 'STARTED', - environment_variables: { 'unicorn' => 'horn' } - ) - end - - before do - space.organization.add_user(user) - app_model.lifecycle_data.buildpacks = [buildpack.name] - app_model.lifecycle_data.stack = stack.name - app_model.lifecycle_data.save - app_model.add_process(VCAP::CloudController::ProcessModel.make(instances: 1)) - app_model.add_process(VCAP::CloudController::ProcessModel.make(instances: 2)) - end - - context 'when getting an app' do - let(:api_call) { ->(user_headers) { get "/v3/apps/#{app_model.guid}", nil, user_headers } } - - let(:app_model_response_object) do - { - guid: app_model.guid, - created_at: iso8601, - updated_at: iso8601, - name: app_model.name, - state: 'STARTED', - lifecycle: { - type: 'buildpack', - data: { buildpacks: [buildpack.name], stack: app_model.lifecycle_data.stack } - }, - relationships: { - space: { data: { guid: space.guid } }, - current_droplet: { data: { guid: app_model.droplet_guid } } - }, - metadata: { - labels: {}, - annotations: {} - }, - links: { - self: { href: "#{link_prefix}/v3/apps/app1_guid" }, - environment_variables: { href: "#{link_prefix}/v3/apps/app1_guid/environment_variables" }, - space: { href: "#{link_prefix}/v3/spaces/#{space.guid}" }, - processes: { href: "#{link_prefix}/v3/apps/app1_guid/processes" }, - packages: { href: "#{link_prefix}/v3/apps/app1_guid/packages" }, - current_droplet: { href: "#{link_prefix}/v3/apps/app1_guid/droplets/current" }, - droplets: { href: "#{link_prefix}/v3/apps/app1_guid/droplets" }, - tasks: { href: "#{link_prefix}/v3/apps/app1_guid/tasks" }, - start: { href: "#{link_prefix}/v3/apps/app1_guid/actions/start", method: 'POST' }, - stop: { href: "#{link_prefix}/v3/apps/app1_guid/actions/stop", method: 'POST' }, - clear_buildpack_cache: { href: "#{link_prefix}/v3/apps/app1_guid/actions/clear_buildpack_cache", method: 'POST' }, - revisions: { href: "#{link_prefix}/v3/apps/app1_guid/revisions" }, - deployed_revisions: { href: "#{link_prefix}/v3/apps/app1_guid/revisions/deployed" }, - features: { href: "#{link_prefix}/v3/apps/app1_guid/features" } - } - } - end - - let(:expected_codes_and_responses) do - h = Hash.new({ code: 200, response_object: app_model_response_object }.freeze) - h['org_auditor'] = { code: 404 } - h['org_billing_manager'] = { code: 404 } - h['no_role'] = { code: 404 } - h - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - - context 'when the user has permission to view the app' do - before do - space.add_developer(user) - end - - it 'gets a specific app' do - get "/v3/apps/#{app_model.guid}", nil, user_header - expect(last_response.status).to eq(200) - - parsed_response = Oj.load(last_response.body) - expect(parsed_response).to be_a_response_like( - { - 'name' => 'my_app', - 'guid' => app_model.guid, - 'state' => 'STARTED', - 'created_at' => iso8601, - 'updated_at' => iso8601, - 'metadata' => { 'labels' => {}, 'annotations' => {} }, - 'lifecycle' => { - 'type' => 'buildpack', - 'data' => { - 'buildpacks' => ['bp-name'], - 'stack' => 'stack-name' - } - }, - 'relationships' => { - 'space' => { - 'data' => { - 'guid' => space.guid - } - }, - 'current_droplet' => { - 'data' => { - 'guid' => app_model.droplet_guid - } - } - }, - 'links' => { - 'self' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}" }, - 'processes' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/processes" }, - 'packages' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/packages" }, - 'environment_variables' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/environment_variables" }, - 'space' => { 'href' => "#{link_prefix}/v3/spaces/#{space.guid}" }, - 'current_droplet' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/droplets/current" }, - 'droplets' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/droplets" }, - 'tasks' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/tasks" }, - 'start' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/start", 'method' => 'POST' }, - 'stop' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/stop", 'method' => 'POST' }, - 'clear_buildpack_cache' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/clear_buildpack_cache", 'method' => 'POST' }, - 'revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/revisions" }, - 'deployed_revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/revisions/deployed" }, - 'features' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/features" } - } - } - ) - end - - it 'gets a specific app including space' do - get "/v3/apps/#{app_model.guid}?include=space", nil, user_header - expect(last_response.status).to eq(200) - - parsed_response = Oj.load(last_response.body) - expect(parsed_response).to be_a_response_like( - { - 'name' => 'my_app', - 'guid' => app_model.guid, - 'state' => 'STARTED', - 'created_at' => iso8601, - 'updated_at' => iso8601, - 'metadata' => { 'labels' => {}, 'annotations' => {} }, - 'lifecycle' => { - 'type' => 'buildpack', - 'data' => { - 'buildpacks' => ['bp-name'], - 'stack' => 'stack-name' - } - }, - 'relationships' => { - 'space' => { - 'data' => { - 'guid' => space.guid - } - }, - 'current_droplet' => { - 'data' => { - 'guid' => app_model.droplet_guid - } - } - }, - 'links' => { - 'self' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}" }, - 'processes' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/processes" }, - 'packages' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/packages" }, - 'environment_variables' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/environment_variables" }, - 'space' => { 'href' => "#{link_prefix}/v3/spaces/#{space.guid}" }, - 'current_droplet' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/droplets/current" }, - 'droplets' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/droplets" }, - 'tasks' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/tasks" }, - 'start' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/start", 'method' => 'POST' }, - 'stop' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/stop", 'method' => 'POST' }, - 'clear_buildpack_cache' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/clear_buildpack_cache", 'method' => 'POST' }, - 'revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/revisions" }, - 'deployed_revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/revisions/deployed" }, - 'features' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/features" } - }, - 'included' => { - 'spaces' => [{ - 'guid' => space.guid, - 'created_at' => iso8601, - 'updated_at' => iso8601, - 'name' => space.name, - 'relationships' => { - 'organization' => { - 'data' => { - 'guid' => space.organization.guid - } - }, - 'quota' => { - 'data' => nil - } - }, - 'metadata' => { - 'labels' => {}, - 'annotations' => {} - }, - 'links' => { - 'self' => { - 'href' => "#{link_prefix}/v3/spaces/#{space.guid}" - }, - 'organization' => { - 'href' => "#{link_prefix}/v3/organizations/#{space.organization.guid}" - }, - 'features' => { 'href' => %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}/features} }, - 'apply_manifest' => { - 'href' => "#{link_prefix}/v3/spaces/#{space.guid}/actions/apply_manifest", - 'method' => 'POST' - } - } - }] - } - } - ) - end - - it 'gets a specific app including space and org' do - get "/v3/apps/#{app_model.guid}?include=space.organization", nil, user_header - expect(last_response.status).to eq(200) - - parsed_response = Oj.load(last_response.body) - spaces = parsed_response['included']['spaces'] - orgs = parsed_response['included']['organizations'] - - expect(spaces).to be_present - expect(orgs[0]).to be_a_response_like( - { - 'guid' => org.guid, - 'created_at' => iso8601, - 'updated_at' => iso8601, - 'name' => org.name, - 'metadata' => { - 'labels' => {}, - 'annotations' => {} - }, - 'suspended' => false, - 'links' => { - 'self' => { - 'href' => "#{link_prefix}/v3/organizations/#{org.guid}" - }, - 'default_domain' => { - 'href' => "#{link_prefix}/v3/organizations/#{org.guid}/domains/default" - }, - 'domains' => { - 'href' => "#{link_prefix}/v3/organizations/#{org.guid}/domains" - }, - 'quota' => { - 'href' => "#{link_prefix}/v3/organization_quotas/#{org.quota_definition.guid}" - } - }, - 'relationships' => { 'quota' => { 'data' => { 'guid' => org.quota_definition.guid } } } - } - ) - end - end - end - - describe 'GET /v3/apps/:guid/env' do - before do - space.organization.add_user(user) - end - - context 'when getting an apps environment variables' do - let(:api_call) { ->(user_headers) { get "/v3/apps/#{app_model.guid}/env", nil, user_headers } } - let!(:app_model) do - VCAP::CloudController::AppModel.make( - :buildpack, - name: 'my_app', - guid: 'app1_guid', - space: space, - environment_variables: { 'unicorn' => 'horn' } - ) - end - - let(:app_model_response_object) do - { - environment_variables: app_model.environment_variables, - staging_env_json: {}, - running_env_json: {}, - system_env_json: { VCAP_SERVICES: {} }, - application_env_json: anything - } - end - let(:app_model_empty_system_env_response_object) do - { - environment_variables: app_model.environment_variables, - staging_env_json: {}, - running_env_json: {}, - system_env_json: { - redacted_message: '[PRIVATE DATA HIDDEN]' - }, - application_env_json: anything - } - end - - let(:expected_codes_and_responses) do - h = Hash.new({ code: 200, response_object: app_model_response_object }.freeze) - h['space_supporter'] = { code: 200, response_object: app_model_empty_system_env_response_object } - h['global_auditor'] = h['org_manager'] = h['space_manager'] = h['space_auditor'] = { code: 403 } - h['org_auditor'] = h['org_billing_manager'] = h['no_role'] = { code: 404 } - h - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - - context 'when k8s service bindings are enabled' do - let(:app_model_response_object) do - r = super() - r[:system_env_json] = { SERVICE_BINDING_ROOT: '/etc/cf-service-bindings' } - r - end - - before do - app_model.update(service_binding_k8s_enabled: true) - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - - context 'when file-based VCAP service bindings are enabled' do - let(:app_model_response_object) do - r = super() - r[:system_env_json] = { VCAP_SERVICES_FILE_PATH: '/etc/cf-service-bindings/vcap_services' } - r - end - - before do - app_model.update(file_based_vcap_services_enabled: true) - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - end - - context 'when VCAP_SERVICES contains potentially sensitive information' do - before do - group = VCAP::CloudController::EnvironmentVariableGroup.staging - group.environment_json = { STAGING_ENV: 'staging_value' } - group.save - - group = VCAP::CloudController::EnvironmentVariableGroup.running - group.environment_json = { RUNNING_ENV: 'running_value' } - group.save - end - - let(:api_call) { ->(user_headers) { get "/v3/apps/#{app_model.guid}/env", nil, user_headers } } - let(:app_model) do - VCAP::CloudController::AppModel.make( - name: 'my_app', - space: space, - environment_variables: { 'unicorn' => 'horn' } - ) - end - let(:service_instance) do - VCAP::CloudController::ManagedServiceInstance.make( - space: space, - name: 'si-name', - tags: ['50% off'] - ) - end - let(:service_binding) do - VCAP::CloudController::ServiceBinding.make( - service_instance: service_instance, - app: app_model, - syslog_drain_url: 'https://syslog.example.com/drain', - credentials: { password: 'top-secret' } - ) - end - let(:expected_response) do - { - 'staging_env_json' => { - 'STAGING_ENV' => 'staging_value' - }, - 'running_env_json' => { - 'RUNNING_ENV' => 'running_value' - }, - 'environment_variables' => { - 'unicorn' => 'horn' - }, - 'system_env_json' => { - 'VCAP_SERVICES' => { - service_instance.service.label => [ - { - 'name' => 'si-name', - 'instance_guid' => service_instance.guid, - 'instance_name' => 'si-name', - 'binding_guid' => service_binding.guid, - 'binding_name' => nil, - 'credentials' => { 'password' => 'top-secret' }, - 'syslog_drain_url' => 'https://syslog.example.com/drain', - 'volume_mounts' => [], - 'label' => service_instance.service.label, - 'provider' => nil, - 'plan' => service_instance.service_plan.name, - 'tags' => ['50% off'] - } - ] - } - }, - 'application_env_json' => { - 'VCAP_APPLICATION' => { - 'cf_api' => "#{TestConfig.config[:external_protocol]}://#{TestConfig.config[:external_domain]}", - 'limits' => { - 'fds' => 16_384 - }, - 'application_name' => 'my_app', - 'application_uris' => [], - 'name' => 'my_app', - 'organization_id' => space.organization.guid, - 'organization_name' => space.organization.name, - 'space_id' => space.guid, - 'space_name' => space.name, - 'uris' => [], - 'users' => nil, - 'application_id' => app_model.guid - } - } - } - end - - let(:expected_response_system_env_redacted) do - { - 'staging_env_json' => { - 'STAGING_ENV' => 'staging_value' - }, - 'running_env_json' => { - 'RUNNING_ENV' => 'running_value' - }, - 'environment_variables' => { - 'unicorn' => 'horn' - }, - 'system_env_json' => { - 'redacted_message' => '[PRIVATE DATA HIDDEN]' - }, - 'application_env_json' => { - 'VCAP_APPLICATION' => { - 'cf_api' => "#{TestConfig.config[:external_protocol]}://#{TestConfig.config[:external_domain]}", - 'limits' => { - 'fds' => 16_384 - }, - 'application_name' => 'my_app', - 'application_uris' => [], - 'name' => 'my_app', - 'organization_id' => space.organization.guid, - 'organization_name' => space.organization.name, - 'space_id' => space.guid, - 'space_name' => space.name, - 'uris' => [], - 'users' => nil, - 'application_id' => app_model.guid - } - } - } - end - - let(:expected_codes_and_responses) do - h = Hash.new({ code: 403 }.freeze) - h['admin'] = h['admin_read_only'] = h['space_developer'] = { code: 200, response_object: expected_response } - h['space_supporter'] = { code: 200, response_object: expected_response_system_env_redacted } - h['org_auditor'] = h['org_billing_manager'] = h['no_role'] = { code: 404 } - h - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - - 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_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS do - let(:expected_codes_and_responses) do - h = Hash.new({ code: 403 }.freeze) - h['admin'] = h['admin_read_only'] = { code: 200, response_object: expected_response } - h['org_auditor'] = h['org_billing_manager'] = h['no_role'] = { code: 404 } - h - end - end - end - end - end -end diff --git a/spec/request/apps_spec.rb b/spec/request/apps_spec.rb new file mode 100644 index 00000000000..64fcef98a77 --- /dev/null +++ b/spec/request/apps_spec.rb @@ -0,0 +1,3542 @@ +require 'spec_helper' +require 'actions/process_create_from_app_droplet' +require 'request_spec_shared_examples' + +RSpec.describe 'Apps' do + let(:user) { VCAP::CloudController::User.make } + let(:user_header) { headers_for(user, email: user_email, user_name: user_name) } + let(:admin_header) { admin_headers_for(user) } + let(:org) { VCAP::CloudController::Organization.make(created_at: 3.days.ago) } + let(:space) { VCAP::CloudController::Space.make(organization: org) } + let(:stack) { VCAP::CloudController::Stack.make } + let(:user_email) { Sham.email } + let(:user_name) { 'some-username' } + + describe 'POST /v3/apps' do + let(:buildpack) { VCAP::CloudController::Buildpack.make(stack: stack.name) } + let(:create_request) do + { + name: 'my_app', + environment_variables: { open: 'source' }, + lifecycle: { + type: 'buildpack', + data: { + stack: buildpack.stack, + buildpacks: [buildpack.name] + } + }, + relationships: { + space: { + data: { + guid: space.guid + } + } + }, + metadata: { + labels: { + 'release' => 'stable', + 'code.cloudfoundry.org/cloud_controller_ng' => 'awesome' + }, + annotations: { + 'description' => 'gud app', + 'dora.capi.land/stuff' => 'real gud stuff' + } + } + } + end + + context 'permissions for creating an app' do + let(:api_call) { ->(user_headers) { post '/v3/apps', create_request.to_json, user_headers } } + let(:app_model_response_object) do + { + guid: UUID_REGEX, + created_at: iso8601, + updated_at: iso8601, + name: 'my_app', + state: 'STOPPED', + lifecycle: { + type: 'buildpack', + data: { buildpacks: [buildpack.name], stack: stack.name } + }, + relationships: { + space: { data: { guid: space.guid } }, + current_droplet: { data: { guid: nil } } + }, + metadata: { + labels: { + 'code.cloudfoundry.org/cloud_controller_ng' => 'awesome', + 'release' => 'stable' + }, + annotations: { + 'dora.capi.land/stuff' => 'real gud stuff', + 'description' => 'gud app' + } + }, + links: { + self: { href: %r{#{link_prefix}/v3/apps/#{UUID_REGEX}} }, + environment_variables: { href: %r{#{link_prefix}/v3/apps/#{UUID_REGEX}/environment_variables} }, + space: { href: %r{#{link_prefix}/v3/spaces/#{space.guid}} }, + processes: { href: %r{#{link_prefix}/v3/apps/#{UUID_REGEX}/processes} }, + packages: { href: %r{#{link_prefix}/v3/apps/#{UUID_REGEX}/packages} }, + current_droplet: { href: %r{#{link_prefix}/v3/apps/#{UUID_REGEX}/droplets/current} }, + droplets: { href: %r{#{link_prefix}/v3/apps/#{UUID_REGEX}/droplets} }, + tasks: { href: %r{#{link_prefix}/v3/apps/#{UUID_REGEX}/tasks} }, + start: { href: %r{#{link_prefix}/v3/apps/#{UUID_REGEX}/actions/start}, method: 'POST' }, + stop: { href: %r{#{link_prefix}/v3/apps/#{UUID_REGEX}/actions/stop}, method: 'POST' }, + clear_buildpack_cache: { href: %r{#{link_prefix}/v3/apps/#{UUID_REGEX}/actions/clear_buildpack_cache}, method: 'POST' }, + revisions: { href: %r{#{link_prefix}/v3/apps/#{UUID_REGEX}/revisions} }, + deployed_revisions: { href: %r{#{link_prefix}/v3/apps/#{UUID_REGEX}/revisions/deployed} }, + features: { href: %r{#{link_prefix}/v3/apps/#{UUID_REGEX}/features} } + } + } + end + + let(:expected_codes_and_responses) do + h = Hash.new({ code: 403, errors: CF_NOT_AUTHORIZED }.freeze) + h['org_billing_manager'] = { code: 422 } + h['org_auditor'] = { code: 422 } + h['no_role'] = { code: 422 } + h['admin'] = { + code: 201, + response_object: app_model_response_object + } + h['space_developer'] = { + code: 201, + response_object: app_model_response_object + } + h + end + + before do + space.organization.add_user(user) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + + context 'when organization is suspended' do + let(:expected_codes_and_responses) do + h = super() + h['space_developer'] = { code: 403, errors: CF_ORG_SUSPENDED } + h + end + + before do + org.update(status: VCAP::CloudController::Organization::SUSPENDED) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + end + + context 'when the user can create an app' do + before do + space.organization.add_user(user) + space.add_developer(user) + end + + it 'creates an app' do + post '/v3/apps', create_request.to_json, user_header + expect(last_response.status).to eq(201) + + parsed_response = Oj.load(last_response.body) + app_guid = parsed_response['guid'] + + expect(VCAP::CloudController::AppModel.find(guid: app_guid)).to be + expect(parsed_response).to be_a_response_like( + { + 'name' => 'my_app', + 'guid' => app_guid, + 'state' => 'STOPPED', + 'lifecycle' => { + 'type' => 'buildpack', + 'data' => { + 'buildpacks' => [buildpack.name], + 'stack' => stack.name + } + }, + 'relationships' => { + 'space' => { + 'data' => { + 'guid' => space.guid + } + }, + 'current_droplet' => { + 'data' => { + 'guid' => nil + } + } + }, + 'created_at' => iso8601, + 'updated_at' => iso8601, + 'metadata' => { + 'labels' => { + 'release' => 'stable', + 'code.cloudfoundry.org/cloud_controller_ng' => 'awesome' + }, + 'annotations' => { + 'description' => 'gud app', + 'dora.capi.land/stuff' => 'real gud stuff' + } + }, + 'links' => { + 'self' => { 'href' => "#{link_prefix}/v3/apps/#{app_guid}" }, + 'processes' => { 'href' => "#{link_prefix}/v3/apps/#{app_guid}/processes" }, + 'packages' => { 'href' => "#{link_prefix}/v3/apps/#{app_guid}/packages" }, + 'environment_variables' => { 'href' => "#{link_prefix}/v3/apps/#{app_guid}/environment_variables" }, + 'space' => { 'href' => "#{link_prefix}/v3/spaces/#{space.guid}" }, + 'current_droplet' => { 'href' => "#{link_prefix}/v3/apps/#{app_guid}/droplets/current" }, + 'droplets' => { 'href' => "#{link_prefix}/v3/apps/#{app_guid}/droplets" }, + 'tasks' => { 'href' => "#{link_prefix}/v3/apps/#{app_guid}/tasks" }, + 'start' => { 'href' => "#{link_prefix}/v3/apps/#{app_guid}/actions/start", 'method' => 'POST' }, + 'stop' => { 'href' => "#{link_prefix}/v3/apps/#{app_guid}/actions/stop", 'method' => 'POST' }, + 'clear_buildpack_cache' => { 'href' => "#{link_prefix}/v3/apps/#{app_guid}/actions/clear_buildpack_cache", 'method' => 'POST' }, + 'revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_guid}/revisions" }, + 'deployed_revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_guid}/revisions/deployed" }, + 'features' => { 'href' => "#{link_prefix}/v3/apps/#{app_guid}/features" } + } + } + ) + + event = VCAP::CloudController::Event.last + expect(event.values).to include({ + type: 'audit.app.create', + actee: app_guid, + actee_type: 'app', + actee_name: 'my_app', + actor: user.guid, + actor_type: 'user', + actor_name: user_email, + actor_username: user_name, + space_guid: space.guid, + organization_guid: space.organization.guid + }) + end + + it 'creates an empty web process with the same guid as the app (so it is visible on the v2 apps api)' do + post '/v3/apps', create_request.to_json, user_header + expect(last_response.status).to eq(201) + + parsed_response = Oj.load(last_response.body) + app_guid = parsed_response['guid'] + expect(VCAP::CloudController::AppModel.find(guid: app_guid)).not_to be_nil + expect(VCAP::CloudController::ProcessModel.find(guid: app_guid)).not_to be_nil + end + + context 'telemetry' do + let(:logger_spy) { spy('logger') } + + before do + allow(VCAP::CloudController::TelemetryLogger).to receive(:logger).and_return(logger_spy) + end + + it 'logs the required fields when the app is created' do + Timecop.freeze do + post '/v3/apps', create_request.to_json, user_header + + parsed_response = Oj.load(last_response.body) + app_guid = parsed_response['guid'] + + expected_json = { + 'telemetry-source' => 'cloud_controller_ng', + 'telemetry-time' => Time.now.to_datetime.rfc3339, + 'create-app' => { + 'api-version' => 'v3', + 'app-id' => OpenSSL::Digest::SHA256.hexdigest(app_guid), + 'user-id' => OpenSSL::Digest::SHA256.hexdigest(user.guid) + } + }.to_json + expect(logger_spy).to have_received(:info).with(expected_json) + expect(last_response.status).to eq(201), last_response.body + end + end + end + + context 'Docker app' do + before do + VCAP::CloudController::FeatureFlag.make(name: 'diego_docker', enabled: true, error_message: nil) + end + + it 'create a docker app' do + create_request = { + name: 'my_app', + environment_variables: { open: 'source' }, + lifecycle: { + type: 'docker', + data: {} + }, + relationships: { + space: { data: { guid: space.guid } } + } + } + + post '/v3/apps', create_request.to_json, user_header.merge({ 'CONTENT_TYPE' => 'application/json' }) + expect(last_response.status).to eq(201), last_response.body + + created_app = VCAP::CloudController::AppModel.last + expected_response = { + 'name' => 'my_app', + 'guid' => created_app.guid, + 'state' => 'STOPPED', + 'lifecycle' => { + 'type' => 'docker', + 'data' => {} + }, + 'relationships' => { + 'space' => { + 'data' => { + 'guid' => space.guid + } + }, + 'current_droplet' => { + 'data' => { + 'guid' => nil + } + } + }, + 'created_at' => iso8601, + 'updated_at' => iso8601, + 'metadata' => { 'labels' => {}, 'annotations' => {} }, + 'links' => { + 'self' => { 'href' => "#{link_prefix}/v3/apps/#{created_app.guid}" }, + 'processes' => { 'href' => "#{link_prefix}/v3/apps/#{created_app.guid}/processes" }, + 'packages' => { 'href' => "#{link_prefix}/v3/apps/#{created_app.guid}/packages" }, + 'environment_variables' => { 'href' => "#{link_prefix}/v3/apps/#{created_app.guid}/environment_variables" }, + 'space' => { 'href' => "#{link_prefix}/v3/spaces/#{space.guid}" }, + 'current_droplet' => { 'href' => "#{link_prefix}/v3/apps/#{created_app.guid}/droplets/current" }, + 'droplets' => { 'href' => "#{link_prefix}/v3/apps/#{created_app.guid}/droplets" }, + 'tasks' => { 'href' => "#{link_prefix}/v3/apps/#{created_app.guid}/tasks" }, + 'start' => { 'href' => "#{link_prefix}/v3/apps/#{created_app.guid}/actions/start", 'method' => 'POST' }, + 'stop' => { 'href' => "#{link_prefix}/v3/apps/#{created_app.guid}/actions/stop", 'method' => 'POST' }, + 'clear_buildpack_cache' => { 'href' => "#{link_prefix}/v3/apps/#{created_app.guid}/actions/clear_buildpack_cache", 'method' => 'POST' }, + 'revisions' => { 'href' => "#{link_prefix}/v3/apps/#{created_app.guid}/revisions" }, + 'deployed_revisions' => { 'href' => "#{link_prefix}/v3/apps/#{created_app.guid}/revisions/deployed" }, + 'features' => { 'href' => "#{link_prefix}/v3/apps/#{created_app.guid}/features" } + } + } + + parsed_response = Oj.load(last_response.body) + expect(parsed_response).to be_a_response_like(expected_response) + + event = VCAP::CloudController::Event.last + expect(event.values).to include( + type: 'audit.app.create', + actee: created_app.guid, + actee_type: 'app', + actee_name: 'my_app', + actor: user.guid, + actor_type: 'user', + actor_name: user_email, + actor_username: user_name, + space_guid: space.guid, + organization_guid: space.organization.guid + ) + end + end + + context 'cc.default_app_lifecycle' do + let(:create_request) do + { + name: 'my_app', + relationships: { + space: { + data: { + guid: space.guid + } + } + } + } + end + + context 'cc.default_app_lifecycle is set to buildpack' do + before do + TestConfig.override(default_app_lifecycle: 'buildpack') + end + + it 'creates an app with the buildpack lifecycle when none is specified in the request' do + post '/v3/apps', create_request.to_json, user_header + + expect(last_response.status).to eq(201) + parsed_response = Oj.load(last_response.body) + expect(parsed_response['lifecycle']['type']).to eq('buildpack') + end + end + end + end + + context 'stack state validation' do + before do + space.organization.add_user(user) + space.add_developer(user) + end + + context 'when stack is DISABLED' do + let(:disabled_stack) { VCAP::CloudController::Stack.make(name: 'disabled-stack', state: 'DISABLED') } + let(:create_request) do + { + name: 'my_app', + lifecycle: { type: 'buildpack', data: { stack: disabled_stack.name } }, + relationships: { space: { data: { guid: space.guid } } } + } + end + + it 'returns 422 with error message' do + post '/v3/apps', create_request.to_json, user_header + + expect(last_response.status).to eq(422) + expect(parsed_response['errors'].first['detail']).to include('DISABLED') + end + end + + context 'when stack is RESTRICTED' do + let(:restricted_stack) { VCAP::CloudController::Stack.make(name: 'restricted-stack', state: 'RESTRICTED') } + let(:create_request) do + { + name: 'my_app', + lifecycle: { type: 'buildpack', data: { stack: restricted_stack.name } }, + relationships: { space: { data: { guid: space.guid } } } + } + end + + it 'returns 422 with error message for new apps' do + post '/v3/apps', create_request.to_json, user_header + + expect(last_response.status).to eq(422) + expect(parsed_response['errors'].first['detail']).to include('RESTRICTED') + end + end + + context 'when stack is DEPRECATED' do + let(:deprecated_stack) { VCAP::CloudController::Stack.make(name: 'deprecated-stack', state: 'DEPRECATED') } + let(:create_request) do + { + name: 'my_app', + lifecycle: { type: 'buildpack', data: { stack: deprecated_stack.name } }, + relationships: { space: { data: { guid: space.guid } } } + } + end + + it 'creates the app without warnings in response body' do + post '/v3/apps', create_request.to_json, user_header + + expect(last_response.status).to eq(201) + expect(parsed_response).not_to have_key('warnings') + end + + it 'includes warnings in X-Cf-Warnings header' do + post '/v3/apps', create_request.to_json, user_header + + expect(last_response.status).to eq(201) + expect(last_response.headers['X-Cf-Warnings']).to be_present + decoded_warning = CGI.unescape(last_response.headers['X-Cf-Warnings']) + expect(decoded_warning).to include('DEPRECATED') + end + end + + context 'when stack is ACTIVE' do + let(:active_stack) { VCAP::CloudController::Stack.make(name: 'active-stack', state: 'ACTIVE') } + let(:create_request) do + { + name: 'my_app', + lifecycle: { type: 'buildpack', data: { stack: active_stack.name } }, + relationships: { space: { data: { guid: space.guid } } } + } + end + + it 'creates the app without warnings' do + post '/v3/apps', create_request.to_json, user_header + + expect(last_response.status).to eq(201) + expect(parsed_response).not_to have_key('warnings') + expect(last_response.headers['X-Cf-Warnings']).to be_nil + end + end + end + end + + describe 'GET /v3/apps' do + before do + space.organization.add_user(user) + end + + context 'listing all apps' do + let(:api_call) { ->(user_headers) { get '/v3/apps', nil, user_headers } } + let(:space2) { VCAP::CloudController::Space.make(organization: org) } + let(:buildpack_lifecycle) { VCAP::CloudController::BuildpackLifecycleDataModel.make(stack: 'cool-stack', app: app_model1) } + let(:app_model1) { VCAP::CloudController::AppModel.make(guid: 'app1_guid', name: 'name1', space: space) } + let(:app_model2) { VCAP::CloudController::AppModel.make(guid: 'app2_guid', name: 'name2', space: space2) } + + let(:app_model1_response_object) do + { + guid: app_model1.guid, + created_at: iso8601, + updated_at: iso8601, + name: app_model1.name, + state: 'STOPPED', + lifecycle: { + type: 'buildpack', + data: { buildpacks: [], stack: app_model1.lifecycle_data.stack } + }, + relationships: { + space: { data: { guid: space.guid } }, + current_droplet: { data: { guid: nil } } + }, + metadata: { + labels: {}, + annotations: {} + }, + links: { + self: { href: "#{link_prefix}/v3/apps/app1_guid" }, + environment_variables: { href: "#{link_prefix}/v3/apps/app1_guid/environment_variables" }, + space: { href: "#{link_prefix}/v3/spaces/#{space.guid}" }, + processes: { href: "#{link_prefix}/v3/apps/app1_guid/processes" }, + packages: { href: "#{link_prefix}/v3/apps/app1_guid/packages" }, + current_droplet: { href: "#{link_prefix}/v3/apps/app1_guid/droplets/current" }, + droplets: { href: "#{link_prefix}/v3/apps/app1_guid/droplets" }, + tasks: { href: "#{link_prefix}/v3/apps/app1_guid/tasks" }, + start: { href: "#{link_prefix}/v3/apps/app1_guid/actions/start", method: 'POST' }, + stop: { href: "#{link_prefix}/v3/apps/app1_guid/actions/stop", method: 'POST' }, + clear_buildpack_cache: { href: "#{link_prefix}/v3/apps/app1_guid/actions/clear_buildpack_cache", method: 'POST' }, + revisions: { href: "#{link_prefix}/v3/apps/app1_guid/revisions" }, + deployed_revisions: { href: "#{link_prefix}/v3/apps/app1_guid/revisions/deployed" }, + features: { href: "#{link_prefix}/v3/apps/app1_guid/features" } + } + } + end + + let(:app_model2_response_object) do + { + guid: app_model2.guid, + created_at: iso8601, + updated_at: iso8601, + name: app_model2.name, + state: 'STOPPED', + lifecycle: { + type: 'buildpack', + data: { buildpacks: [], stack: app_model2.lifecycle_data.stack } + }, + relationships: { + space: { data: { guid: space2.guid } }, + current_droplet: { data: { guid: nil } } + }, + metadata: { + labels: {}, + annotations: {} + }, + links: { + self: { href: "#{link_prefix}/v3/apps/app2_guid" }, + environment_variables: { href: "#{link_prefix}/v3/apps/app2_guid/environment_variables" }, + space: { href: "#{link_prefix}/v3/spaces/#{space2.guid}" }, + processes: { href: "#{link_prefix}/v3/apps/app2_guid/processes" }, + packages: { href: "#{link_prefix}/v3/apps/app2_guid/packages" }, + current_droplet: { href: "#{link_prefix}/v3/apps/app2_guid/droplets/current" }, + droplets: { href: "#{link_prefix}/v3/apps/app2_guid/droplets" }, + tasks: { href: "#{link_prefix}/v3/apps/app2_guid/tasks" }, + start: { href: "#{link_prefix}/v3/apps/app2_guid/actions/start", method: 'POST' }, + stop: { href: "#{link_prefix}/v3/apps/app2_guid/actions/stop", method: 'POST' }, + clear_buildpack_cache: { href: "#{link_prefix}/v3/apps/app2_guid/actions/clear_buildpack_cache", method: 'POST' }, + revisions: { href: "#{link_prefix}/v3/apps/app2_guid/revisions" }, + deployed_revisions: { href: "#{link_prefix}/v3/apps/app2_guid/revisions/deployed" }, + features: { href: "#{link_prefix}/v3/apps/app2_guid/features" } + } + } + end + + let(:expected_codes_and_responses) do + h = Hash.new({ code: 200, response_objects: [app_model1_response_object, app_model2_response_object] }.freeze) + + h['org_auditor'] = { + code: 200, + response_objects: [] + } + + h['org_billing_manager'] = { + code: 200, + response_objects: [] + } + + h['space_manager'] = { + code: 200, + response_objects: [ + app_model1_response_object + ] + } + + h['space_auditor'] = { + code: 200, + response_objects: [ + app_model1_response_object + ] + } + + h['space_developer'] = { + code: 200, + response_objects: [ + app_model1_response_object + ] + } + + h['space_supporter'] = { + code: 200, + response_objects: [ + app_model1_response_object + ] + } + + h['no_role'] = { code: 200, response_objects: [] } + h + end + + it_behaves_like 'permissions for list endpoint', ALL_PERMISSIONS + end + + describe 'query list parameters' do + it_behaves_like 'list query endpoint' do + let(:request) { 'v3/apps' } + + let(:message) { VCAP::CloudController::AppsListMessage } + + let(:params) do + { + page: '2', + per_page: '10', + order_by: 'updated_at', + names: 'foo', + guids: 'foo', + organization_guids: 'foo', + space_guids: 'foo', + stacks: 'cf', + include: 'space', + lifecycle_type: 'buildpack', + label_selector: 'foo,bar', + created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", + updated_ats: { gt: Time.now.utc.iso8601 } + } + end + + let!(:app_model) { VCAP::CloudController::AppModel.make } + end + end + + context 'pagination' do + before do + space.add_developer(user) + end + + it 'returns a paginated list of apps the user has access to' do + buildpack = VCAP::CloudController::Buildpack.make(name: 'bp-name') + stack = VCAP::CloudController::Stack.make(name: 'stack-name') + + app_model1 = VCAP::CloudController::AppModel.make(name: 'name1', space: space, desired_state: 'STOPPED') + app_model1.lifecycle_data.update( + buildpacks: [buildpack.name], + stack: stack.name + ) + + app_model2 = VCAP::CloudController::AppModel.make( + :docker, + name: 'name2', + space: space, + desired_state: 'STARTED' + ) + VCAP::CloudController::AppModel.make(space:) + VCAP::CloudController::AppModel.make + + get '/v3/apps?per_page=2&include=space', nil, user_header + expect(last_response.status).to eq(200) + + parsed_response = Oj.load(last_response.body) + expect(parsed_response).to be_a_response_like( + { + 'pagination' => { + 'total_results' => 3, + 'total_pages' => 2, + 'first' => { 'href' => "#{link_prefix}/v3/apps?include=space&page=1&per_page=2" }, + 'last' => { 'href' => "#{link_prefix}/v3/apps?include=space&page=2&per_page=2" }, + 'next' => { 'href' => "#{link_prefix}/v3/apps?include=space&page=2&per_page=2" }, + 'previous' => nil + }, + 'resources' => [ + { + 'guid' => app_model1.guid, + 'name' => 'name1', + 'state' => 'STOPPED', + 'lifecycle' => { + 'type' => 'buildpack', + 'data' => { + 'buildpacks' => ['bp-name'], + 'stack' => 'stack-name' + } + }, + 'relationships' => { + 'space' => { + 'data' => { + 'guid' => space.guid + } + }, + 'current_droplet' => { + 'data' => { + 'guid' => nil + } + } + }, + 'created_at' => iso8601, + 'updated_at' => iso8601, + 'metadata' => { 'labels' => {}, 'annotations' => {} }, + 'links' => { + 'self' => { 'href' => "#{link_prefix}/v3/apps/#{app_model1.guid}" }, + 'processes' => { 'href' => "#{link_prefix}/v3/apps/#{app_model1.guid}/processes" }, + 'packages' => { 'href' => "#{link_prefix}/v3/apps/#{app_model1.guid}/packages" }, + 'environment_variables' => { 'href' => "#{link_prefix}/v3/apps/#{app_model1.guid}/environment_variables" }, + 'space' => { 'href' => "#{link_prefix}/v3/spaces/#{space.guid}" }, + 'current_droplet' => { 'href' => "#{link_prefix}/v3/apps/#{app_model1.guid}/droplets/current" }, + 'droplets' => { 'href' => "#{link_prefix}/v3/apps/#{app_model1.guid}/droplets" }, + 'tasks' => { 'href' => "#{link_prefix}/v3/apps/#{app_model1.guid}/tasks" }, + 'start' => { 'href' => "#{link_prefix}/v3/apps/#{app_model1.guid}/actions/start", 'method' => 'POST' }, + 'stop' => { 'href' => "#{link_prefix}/v3/apps/#{app_model1.guid}/actions/stop", 'method' => 'POST' }, + 'clear_buildpack_cache' => { 'href' => "#{link_prefix}/v3/apps/#{app_model1.guid}/actions/clear_buildpack_cache", 'method' => 'POST' }, + 'revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model1.guid}/revisions" }, + 'deployed_revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model1.guid}/revisions/deployed" }, + 'features' => { 'href' => "#{link_prefix}/v3/apps/#{app_model1.guid}/features" } + } + }, + { + 'guid' => app_model2.guid, + 'name' => 'name2', + 'state' => 'STARTED', + 'lifecycle' => { + 'type' => 'docker', + 'data' => {} + }, + 'relationships' => { + 'space' => { + 'data' => { + 'guid' => space.guid + } + }, + 'current_droplet' => { + 'data' => { + 'guid' => nil + } + } + }, + 'created_at' => iso8601, + 'updated_at' => iso8601, + 'metadata' => { 'labels' => {}, 'annotations' => {} }, + 'links' => { + 'self' => { 'href' => "#{link_prefix}/v3/apps/#{app_model2.guid}" }, + 'processes' => { 'href' => "#{link_prefix}/v3/apps/#{app_model2.guid}/processes" }, + 'packages' => { 'href' => "#{link_prefix}/v3/apps/#{app_model2.guid}/packages" }, + 'environment_variables' => { 'href' => "#{link_prefix}/v3/apps/#{app_model2.guid}/environment_variables" }, + 'space' => { 'href' => "#{link_prefix}/v3/spaces/#{space.guid}" }, + 'current_droplet' => { 'href' => "#{link_prefix}/v3/apps/#{app_model2.guid}/droplets/current" }, + 'droplets' => { 'href' => "#{link_prefix}/v3/apps/#{app_model2.guid}/droplets" }, + 'tasks' => { 'href' => "#{link_prefix}/v3/apps/#{app_model2.guid}/tasks" }, + 'start' => { 'href' => "#{link_prefix}/v3/apps/#{app_model2.guid}/actions/start", 'method' => 'POST' }, + 'stop' => { 'href' => "#{link_prefix}/v3/apps/#{app_model2.guid}/actions/stop", 'method' => 'POST' }, + 'clear_buildpack_cache' => { 'href' => "#{link_prefix}/v3/apps/#{app_model2.guid}/actions/clear_buildpack_cache", 'method' => 'POST' }, + 'revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model2.guid}/revisions" }, + 'deployed_revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model2.guid}/revisions/deployed" }, + 'features' => { 'href' => "#{link_prefix}/v3/apps/#{app_model2.guid}/features" } + } + } + ], + 'included' => { + 'spaces' => [{ + 'guid' => space.guid, + 'created_at' => iso8601, + 'updated_at' => iso8601, + 'name' => space.name, + 'relationships' => { + 'organization' => { + 'data' => { + 'guid' => space.organization.guid + } + }, + 'quota' => { + 'data' => nil + } + }, + 'metadata' => { + 'labels' => {}, + 'annotations' => {} + }, + 'links' => { + 'self' => { + 'href' => "#{link_prefix}/v3/spaces/#{space.guid}" + }, + 'organization' => { + 'href' => "#{link_prefix}/v3/organizations/#{space.organization.guid}" + }, + 'features' => { 'href' => %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}/features} }, + 'apply_manifest' => { + 'href' => "#{link_prefix}/v3/spaces/#{space.guid}/actions/apply_manifest", + 'method' => 'POST' + } + } + }] + } + } + ) + end + end + + context 'filtering by timestamps' do + before do + VCAP::CloudController::AppModel.plugin :timestamps, update_on_create: false + end + + # .make updates the resource after creating it, over writing our passed in updated_at timestamp + # Therefore we cannot use shared_examples as the updated_at will not be as written + let!(:resource_1) { VCAP::CloudController::AppModel.create(name: '1', created_at: '2020-05-26T18:47:01Z', updated_at: '2020-05-26T18:47:01Z', space: space) } + let!(:resource_2) { VCAP::CloudController::AppModel.create(name: '2', created_at: '2020-05-26T18:47:02Z', updated_at: '2020-05-26T18:47:02Z', space: space) } + let!(:resource_3) { VCAP::CloudController::AppModel.create(name: '3', created_at: '2020-05-26T18:47:03Z', updated_at: '2020-05-26T18:47:03Z', space: space) } + let!(:resource_4) { VCAP::CloudController::AppModel.create(name: '4', created_at: '2020-05-26T18:47:04Z', updated_at: '2020-05-26T18:47:04Z', space: space) } + + after do + VCAP::CloudController::AppModel.plugin :timestamps, update_on_create: true + end + + it 'filters by the created at' do + get "/v3/apps?created_ats[lt]=#{resource_3.created_at.iso8601}", nil, admin_header + + expect(last_response).to have_status_code(200) + expect(parsed_response['resources'].pluck('guid')).to contain_exactly(resource_1.guid, resource_2.guid) + end + + it 'filters ny the updated_at' do + get "/v3/apps?updated_ats[lt]=#{resource_3.updated_at.iso8601}", nil, admin_header + + expect(last_response).to have_status_code(200) + expect(parsed_response['resources'].pluck('guid')).to contain_exactly(resource_1.guid, resource_2.guid) + end + end + + context 'faceted search' do + let(:admin_header) { headers_for(user, scopes: %w[cloud_controller.admin]) } + + it 'filters by guids' do + app_model1 = VCAP::CloudController::AppModel.make(name: 'name1') + VCAP::CloudController::AppModel.make(name: 'name2') + app_model3 = VCAP::CloudController::AppModel.make(name: 'name3') + + get "/v3/apps?guids=#{app_model1.guid}%2C#{app_model3.guid}", nil, admin_header + + expected_pagination = { + 'total_results' => 2, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/apps?guids=#{app_model1.guid}%2C#{app_model3.guid}&page=1&per_page=50" }, + 'last' => { 'href' => "#{link_prefix}/v3/apps?guids=#{app_model1.guid}%2C#{app_model3.guid}&page=1&per_page=50" }, + 'next' => nil, + 'previous' => nil + } + + parsed_response = Oj.load(last_response.body) + + expect(last_response.status).to eq(200) + expect(parsed_response['resources'].pluck('name')).to eq(%w[name1 name3]) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + + it 'filters by names' do + VCAP::CloudController::AppModel.make(name: 'name1') + VCAP::CloudController::AppModel.make(name: 'name2') + VCAP::CloudController::AppModel.make(name: 'name3') + + get '/v3/apps?names=name1%2Cname2', nil, admin_header + + expected_pagination = { + 'total_results' => 2, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/apps?names=name1%2Cname2&page=1&per_page=50" }, + 'last' => { 'href' => "#{link_prefix}/v3/apps?names=name1%2Cname2&page=1&per_page=50" }, + 'next' => nil, + 'previous' => nil + } + + parsed_response = Oj.load(last_response.body) + + expect(last_response.status).to eq(200) + expect(parsed_response['resources'].pluck('name')).to eq(%w[name1 name2]) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + + it 'filters by organizations' do + app_model1 = VCAP::CloudController::AppModel.make(name: 'name1') + VCAP::CloudController::AppModel.make(name: 'name2') + app_model3 = VCAP::CloudController::AppModel.make(name: 'name3') + + get "/v3/apps?organization_guids=#{app_model1.organization.guid}%2C#{app_model3.organization.guid}", nil, admin_header + + expected_pagination = { + 'total_results' => 2, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/apps?organization_guids=#{app_model1.organization.guid}%2C#{app_model3.organization.guid}&page=1&per_page=50" }, + 'last' => { 'href' => "#{link_prefix}/v3/apps?organization_guids=#{app_model1.organization.guid}%2C#{app_model3.organization.guid}&page=1&per_page=50" }, + 'next' => nil, + 'previous' => nil + } + + parsed_response = Oj.load(last_response.body) + + expect(last_response.status).to eq(200) + expect(parsed_response['resources'].pluck('name')).to eq(%w[name1 name3]) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + + it 'filters by spaces' do + app_model1 = VCAP::CloudController::AppModel.make(name: 'name1') + VCAP::CloudController::AppModel.make(name: 'name2') + app_model3 = VCAP::CloudController::AppModel.make(name: 'name3') + + get "/v3/apps?space_guids=#{app_model1.space.guid}%2C#{app_model3.space.guid}", nil, admin_header + + expected_pagination = { + 'total_results' => 2, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/apps?page=1&per_page=50&space_guids=#{app_model1.space.guid}%2C#{app_model3.space.guid}" }, + 'last' => { 'href' => "#{link_prefix}/v3/apps?page=1&per_page=50&space_guids=#{app_model1.space.guid}%2C#{app_model3.space.guid}" }, + 'next' => nil, + 'previous' => nil + } + + parsed_response = Oj.load(last_response.body) + + expect(last_response.status).to eq(200) + expect(parsed_response['resources'].pluck('name')).to eq(%w[name1 name3]) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + + it 'filters by stack names' do + app_model1 = VCAP::CloudController::AppModel.make(name: 'name1') + app_model2 = VCAP::CloudController::AppModel.make(name: 'name2') + app_model3 = VCAP::CloudController::AppModel.make(name: 'name3') + + stack2 = VCAP::CloudController::Stack.make(name: 'name2') + stack3 = VCAP::CloudController::Stack.make(name: 'name3') + + app_model1.lifecycle_data.stack = stack2.name + app_model1.lifecycle_data.save + + app_model2.lifecycle_data.stack = stack2.name + app_model2.lifecycle_data.save + + app_model3.lifecycle_data.stack = stack3.name + app_model3.lifecycle_data.save + + get "/v3/apps?stacks=#{stack2.name}", nil, admin_header + + expected_pagination = { + 'total_results' => 2, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/apps?page=1&per_page=50&stacks=#{stack2.name}" }, + 'last' => { 'href' => "#{link_prefix}/v3/apps?page=1&per_page=50&stacks=#{stack2.name}" }, + 'next' => nil, + 'previous' => nil + } + + parsed_response = Oj.load(last_response.body) + + expect(last_response.status).to eq(200) + expect(parsed_response['resources'].pluck('name')).to eq(%w[name1 name2]) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + + it 'filters by null stacks' do + app_model1 = VCAP::CloudController::AppModel.make(name: 'name1') + app_model2 = VCAP::CloudController::AppModel.make(name: 'name2') + app_model3 = VCAP::CloudController::AppModel.make(name: 'name3') + + stack2 = VCAP::CloudController::Stack.make(name: 'name2') + stack3 = VCAP::CloudController::Stack.make(name: 'name3') + + app_model1.lifecycle_data.stack = nil + app_model1.lifecycle_data.save + + app_model2.lifecycle_data.stack = stack2.name + app_model2.lifecycle_data.save + + app_model3.lifecycle_data.stack = stack3.name + app_model3.lifecycle_data.save + + get '/v3/apps?stacks=', nil, admin_header + + expected_pagination = { + 'total_results' => 1, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/apps?page=1&per_page=50&stacks=" }, + 'last' => { 'href' => "#{link_prefix}/v3/apps?page=1&per_page=50&stacks=" }, + 'next' => nil, + 'previous' => nil + } + + parsed_response = Oj.load(last_response.body) + + expect(last_response.status).to eq(200) + expect(parsed_response['resources'].pluck('name')).to eq(['name1']) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + + it 'filters by lifecycle_type' do + VCAP::CloudController::AppModel.make(name: 'name1') + docker_app_model = VCAP::CloudController::AppModel.make(name: 'name2') + VCAP::CloudController::AppModel.make(name: 'name3') + + docker_app_model.buildpack_lifecycle_data = nil + docker_app_model.save + + get '/v3/apps?lifecycle_type=buildpack', nil, admin_header + + expected_pagination = { + 'total_results' => 2, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/apps?lifecycle_type=buildpack&page=1&per_page=50" }, + 'last' => { 'href' => "#{link_prefix}/v3/apps?lifecycle_type=buildpack&page=1&per_page=50" }, + 'next' => nil, + 'previous' => nil + } + + parsed_response = Oj.load(last_response.body) + + expect(last_response.status).to eq(200) + expect(parsed_response['resources'].pluck('name')).to eq(%w[name1 name3]) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + end + + context 'ordering' do + before do + space.add_developer(user) + end + + it 'can order by name' do + VCAP::CloudController::AppModel.make(space: space, name: 'zed') + VCAP::CloudController::AppModel.make(space: space, name: 'alpha') + VCAP::CloudController::AppModel.make(space: space, name: 'gamma') + VCAP::CloudController::AppModel.make(space: space, name: 'delta') + VCAP::CloudController::AppModel.make(space: space, name: 'theta') + + ascending = %w[alpha delta gamma theta zed] + descending = ascending.reverse + + # ASCENDING + get '/v3/apps?order_by=name', nil, user_header + expect(last_response.status).to eq(200) + parsed_response = Oj.load(last_response.body) + app_names = parsed_response['resources'].pluck('name') + expect(app_names).to eq(ascending) + expect(parsed_response['pagination']['first']['href']).to include("order_by=#{CGI.escape('+')}name") + + # DESCENDING + get '/v3/apps?order_by=-name', nil, user_header + expect(last_response.status).to eq(200) + parsed_response = Oj.load(last_response.body) + app_names = parsed_response['resources'].pluck('name') + expect(app_names).to eq(descending) + expect(parsed_response['pagination']['first']['href']).to include('order_by=-name') + end + + it 'can order by state' do + VCAP::CloudController::AppModel.make(space: space, desired_state: 'STARTED') + VCAP::CloudController::AppModel.make(space: space, desired_state: 'STOPPED') + VCAP::CloudController::AppModel.make(space: space, desired_state: 'STARTED') + VCAP::CloudController::AppModel.make(space: space, desired_state: 'STOPPED') + ascending = %w[STARTED STARTED STOPPED STOPPED] + descending = ascending.reverse + + # ASCENDING + get '/v3/apps?order_by=state', nil, user_header + expect(last_response.status).to eq(200) + parsed_response = Oj.load(last_response.body) + app_states = parsed_response['resources'].pluck('state') + expect(app_states).to eq(ascending) + expect(parsed_response['pagination']['first']['href']).to include("order_by=#{CGI.escape('+')}state") + + # DESCENDING + get '/v3/apps?order_by=-state', nil, user_header + expect(last_response.status).to eq(200) + parsed_response = Oj.load(last_response.body) + app_states = parsed_response['resources'].pluck('state') + expect(app_states).to eq(descending) + expect(parsed_response['pagination']['first']['href']).to include('order_by=-state') + end + end + + context 'labels' do + let!(:app1) { VCAP::CloudController::AppModel.make(name: 'name1') } + let!(:app1_label) { VCAP::CloudController::AppLabelModel.make(resource_guid: app1.guid, key_name: 'foo', value: 'bar') } + + let!(:app2) { VCAP::CloudController::AppModel.make(name: 'name2') } + let!(:app2_label) { VCAP::CloudController::AppLabelModel.make(resource_guid: app2.guid, key_name: 'foo', value: 'funky') } + let!(:app2__exclusive_label) { VCAP::CloudController::AppLabelModel.make(resource_guid: app2.guid, key_name: 'santa', value: 'claus') } + + let(:admin_header) { headers_for(user, scopes: %w[cloud_controller.admin]) } + + it 'returns a 200 and the filtered apps for "in" label selector' do + get '/v3/apps?label_selector=foo in (bar)', nil, admin_header + + parsed_response = Oj.load(last_response.body) + + expected_pagination = { + 'total_results' => 1, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/apps?label_selector=foo+in+%28bar%29&page=1&per_page=50" }, + 'last' => { 'href' => "#{link_prefix}/v3/apps?label_selector=foo+in+%28bar%29&page=1&per_page=50" }, + 'next' => nil, + 'previous' => nil + } + + expect(last_response.status).to eq(200) + expect(parsed_response['resources'].pluck('guid')).to contain_exactly(app1.guid) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + + it 'returns a 200 and the filtered apps for "notin" label selector' do + get '/v3/apps?label_selector=foo notin (bar)', nil, admin_header + + parsed_response = Oj.load(last_response.body) + + expected_pagination = { + 'total_results' => 1, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/apps?label_selector=foo+notin+%28bar%29&page=1&per_page=50" }, + 'last' => { 'href' => "#{link_prefix}/v3/apps?label_selector=foo+notin+%28bar%29&page=1&per_page=50" }, + 'next' => nil, + 'previous' => nil + } + + expect(last_response.status).to eq(200) + expect(parsed_response['resources'].pluck('guid')).to contain_exactly(app2.guid) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + + it 'returns a 200 and the filtered apps for "=" label selector' do + get '/v3/apps?label_selector=foo=bar', nil, admin_header + + parsed_response = Oj.load(last_response.body) + + expected_pagination = { + 'total_results' => 1, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/apps?label_selector=foo%3Dbar&page=1&per_page=50" }, + 'last' => { 'href' => "#{link_prefix}/v3/apps?label_selector=foo%3Dbar&page=1&per_page=50" }, + 'next' => nil, + 'previous' => nil + } + + expect(last_response.status).to eq(200) + expect(parsed_response['resources'].pluck('guid')).to contain_exactly(app1.guid) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + + it 'returns a 200 and the filtered apps for "==" label selector' do + get '/v3/apps?label_selector=foo==bar', nil, admin_header + + parsed_response = Oj.load(last_response.body) + + expected_pagination = { + 'total_results' => 1, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/apps?label_selector=foo%3D%3Dbar&page=1&per_page=50" }, + 'last' => { 'href' => "#{link_prefix}/v3/apps?label_selector=foo%3D%3Dbar&page=1&per_page=50" }, + 'next' => nil, + 'previous' => nil + } + + expect(last_response.status).to eq(200) + expect(parsed_response['resources'].pluck('guid')).to contain_exactly(app1.guid) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + + it 'returns a 200 and the filtered apps for "!=" label selector' do + get '/v3/apps?label_selector=foo!=bar', nil, admin_header + + parsed_response = Oj.load(last_response.body) + + expected_pagination = { + 'total_results' => 1, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/apps?label_selector=foo%21%3Dbar&page=1&per_page=50" }, + 'last' => { 'href' => "#{link_prefix}/v3/apps?label_selector=foo%21%3Dbar&page=1&per_page=50" }, + 'next' => nil, + 'previous' => nil + } + + expect(last_response.status).to eq(200) + expect(parsed_response['resources'].pluck('guid')).to contain_exactly(app2.guid) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + + it 'returns a 200 and the filtered apps for "==" label selector' do + get '/v3/apps?label_selector=foo=funky,santa=claus', nil, admin_header + + parsed_response = Oj.load(last_response.body) + + expected_pagination = { + 'total_results' => 1, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/apps?label_selector=foo%3Dfunky%2Csanta%3Dclaus&page=1&per_page=50" }, + 'last' => { 'href' => "#{link_prefix}/v3/apps?label_selector=foo%3Dfunky%2Csanta%3Dclaus&page=1&per_page=50" }, + 'next' => nil, + 'previous' => nil + } + + expect(last_response.status).to eq(200) + expect(parsed_response['resources'].pluck('guid')).to contain_exactly(app2.guid) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + + it 'returns a 200 and the filtered apps for existence label selector' do + get '/v3/apps?label_selector=santa', nil, admin_header + + parsed_response = Oj.load(last_response.body) + + expected_pagination = { + 'total_results' => 1, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/apps?label_selector=santa&page=1&per_page=50" }, + 'last' => { 'href' => "#{link_prefix}/v3/apps?label_selector=santa&page=1&per_page=50" }, + 'next' => nil, + 'previous' => nil + } + + expect(last_response.status).to eq(200) + expect(parsed_response['resources'].pluck('guid')).to contain_exactly(app2.guid) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + + it 'returns a 200 and the filtered apps for non-existence label selector' do + get '/v3/apps?label_selector=!santa', nil, admin_header + + parsed_response = Oj.load(last_response.body) + + expected_pagination = { + 'total_results' => 1, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/apps?label_selector=%21santa&page=1&per_page=50" }, + 'last' => { 'href' => "#{link_prefix}/v3/apps?label_selector=%21santa&page=1&per_page=50" }, + 'next' => nil, + 'previous' => nil + } + + expect(last_response.status).to eq(200) + expect(parsed_response['resources'].pluck('guid')).to contain_exactly(app1.guid) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + end + + context 'labels and existing filters' do + let!(:space1) { VCAP::CloudController::Space.make } + let!(:space2) { VCAP::CloudController::Space.make } + let!(:app1) { VCAP::CloudController::AppModel.make(name: 'name1', space: space1) } + let!(:app2) { VCAP::CloudController::AppModel.make(name: 'name2', space: space2) } + let!(:app3) { VCAP::CloudController::AppModel.make(name: 'name3', space: space2) } + let!(:app1_label1) { VCAP::CloudController::AppLabelModel.make(resource_guid: app1.guid, key_name: 'foo', value: 'funky') } + let!(:app2_label1) { VCAP::CloudController::AppLabelModel.make(resource_guid: app2.guid, key_name: 'foo', value: 'funky') } + let!(:app2_label2) { VCAP::CloudController::AppLabelModel.make(resource_guid: app2.guid, key_name: 'fruit', value: 'strawberry') } + let!(:app3_label1) { VCAP::CloudController::AppLabelModel.make(resource_guid: app3.guid, key_name: 'fruit', value: 'strawberry') } + + let(:admin_header) { headers_for(user, scopes: %w[cloud_controller.admin]) } + + it 'returns a 200 and the correct app when querying with space guid' do + get "/v3/apps?space_guids=#{space2.guid}&label_selector=foo==funky", nil, admin_header + + parsed_response = Oj.load(last_response.body) + + expected_pagination = { + 'total_results' => 1, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/apps?label_selector=foo%3D%3Dfunky&page=1&per_page=50&space_guids=#{space2.guid}" }, + 'last' => { 'href' => "#{link_prefix}/v3/apps?label_selector=foo%3D%3Dfunky&page=1&per_page=50&space_guids=#{space2.guid}" }, + 'next' => nil, + 'previous' => nil + } + + expect(last_response.status).to eq(200) + expect(parsed_response['resources'].pluck('guid')).to contain_exactly(app2.guid) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + + it 'returns a 200 and the correct app when querying with space guid' do + get "/v3/apps?space_guids=#{space2.guid}&label_selector=fruit==strawberry&names=name2", nil, admin_header + + parsed_response = Oj.load(last_response.body) + + expected_pagination = { + 'total_results' => 1, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/apps?label_selector=fruit%3D%3Dstrawberry&names=name2&page=1&per_page=50&space_guids=#{space2.guid}" }, + 'last' => { 'href' => "#{link_prefix}/v3/apps?label_selector=fruit%3D%3Dstrawberry&names=name2&page=1&per_page=50&space_guids=#{space2.guid}" }, + 'next' => nil, + 'previous' => nil + } + + expect(last_response.status).to eq(200) + expect(parsed_response['resources'].pluck('guid')).to contain_exactly(app2.guid) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + end + + context 'including orgs and spaces' do + it 'presents the apps listed with the orgs and spaces included' do + VCAP::CloudController::AppModel.make(:docker, name: 'name1', guid: 'app1-guid', space: space) + + org1 = space.organization + org2 = VCAP::CloudController::Organization.make(name: 'org2', guid: 'org2-guid', created_at: 1.day.ago) + space2 = VCAP::CloudController::Space.make(name: 'space2', guid: 'space2-guid', organization: org2) + + unused_org = VCAP::CloudController::Organization.make(name: 'unused_org', guid: 'unused_org-guid') + + VCAP::CloudController::Space.make(name: 'unused_space', guid: 'unused_space-guid', organization: unused_org) + + VCAP::CloudController::AppModel.make( + :docker, + name: 'name2', + guid: 'app2-guid', + space: space2 + ) + + get '/v3/apps?per_page=2&include=space,space.organization', nil, admin_header + expect(last_response.status).to eq(200) + + parsed_response = Oj.load(last_response.body) + + expect(parsed_response['included']['organizations'][0]).to be_a_response_like({ + 'guid' => org1.guid, + 'created_at' => iso8601, + 'updated_at' => iso8601, + 'name' => org1.name, + 'metadata' => { + 'labels' => {}, + 'annotations' => {} + }, + 'suspended' => false, + 'links' => { + 'self' => { + 'href' => "#{link_prefix}/v3/organizations/#{org1.guid}" + }, + 'default_domain' => { + 'href' => "#{link_prefix}/v3/organizations/#{org1.guid}/domains/default" + }, + 'domains' => { + 'href' => "#{link_prefix}/v3/organizations/#{org1.guid}/domains" + }, + 'quota' => { + 'href' => "#{link_prefix}/v3/organization_quotas/#{org1.quota_definition.guid}" + } + }, + 'relationships' => { 'quota' => { 'data' => { 'guid' => org1.quota_definition.guid } } } + }) + expect(parsed_response['included']['organizations'][1]).to be_a_response_like({ + 'guid' => org2.guid, + 'created_at' => iso8601, + 'updated_at' => iso8601, + 'name' => org2.name, + 'suspended' => false, + 'metadata' => { + 'labels' => {}, + 'annotations' => {} + }, + 'links' => { + 'self' => { + 'href' => "#{link_prefix}/v3/organizations/#{org2.guid}" + }, + 'default_domain' => { + 'href' => "#{link_prefix}/v3/organizations/#{org2.guid}/domains/default" + }, + 'domains' => { + 'href' => "#{link_prefix}/v3/organizations/#{org2.guid}/domains" + }, + 'quota' => { + 'href' => "#{link_prefix}/v3/organization_quotas/#{org2.quota_definition.guid}" + } + }, + 'relationships' => { 'quota' => { 'data' => { 'guid' => org2.quota_definition.guid } } } + }) + end + + it 'flags unsupported includes that contain supported ones' do + get '/v3/apps?per_page=2&include=space.organization,spaceship,borgs,space', nil, admin_header + expect(last_response.status).to eq(400) + end + + it 'does not include spaces if no one asks for them' do + get '/v3/apps', nil, admin_header + parsed_response = Oj.load(last_response.body) + expect(parsed_response).not_to have_key('included') + end + end + + context 'when including orgs' do + before do + VCAP::CloudController::AppModel.make + end + + it 'eagerly loads spaces to efficiently access space.organization_id' do + expect(VCAP::CloudController::IncludeOrganizationDecorator).to receive(:decorate) do |_, resources| + expect(resources).not_to be_empty + resources.each { |r| expect(r.associations).to include(:space) } + end + + get '/v3/apps?include=space.organization', nil, admin_header + expect(last_response).to have_status_code(200) + end + end + end + + describe 'GET /v3/apps/:guid' do + let!(:buildpack) { VCAP::CloudController::Buildpack.make(name: 'bp-name') } + let!(:stack) { VCAP::CloudController::Stack.make(name: 'stack-name') } + let!(:app_model) do + VCAP::CloudController::AppModel.make( + :buildpack, + name: 'my_app', + guid: 'app1_guid', + space: space, + desired_state: 'STARTED', + environment_variables: { 'unicorn' => 'horn' } + ) + end + + before do + space.organization.add_user(user) + app_model.lifecycle_data.buildpacks = [buildpack.name] + app_model.lifecycle_data.stack = stack.name + app_model.lifecycle_data.save + app_model.add_process(VCAP::CloudController::ProcessModel.make(instances: 1)) + app_model.add_process(VCAP::CloudController::ProcessModel.make(instances: 2)) + end + + context 'when getting an app' do + let(:api_call) { ->(user_headers) { get "/v3/apps/#{app_model.guid}", nil, user_headers } } + + let(:app_model_response_object) do + { + guid: app_model.guid, + created_at: iso8601, + updated_at: iso8601, + name: app_model.name, + state: 'STARTED', + lifecycle: { + type: 'buildpack', + data: { buildpacks: [buildpack.name], stack: app_model.lifecycle_data.stack } + }, + relationships: { + space: { data: { guid: space.guid } }, + current_droplet: { data: { guid: app_model.droplet_guid } } + }, + metadata: { + labels: {}, + annotations: {} + }, + links: { + self: { href: "#{link_prefix}/v3/apps/app1_guid" }, + environment_variables: { href: "#{link_prefix}/v3/apps/app1_guid/environment_variables" }, + space: { href: "#{link_prefix}/v3/spaces/#{space.guid}" }, + processes: { href: "#{link_prefix}/v3/apps/app1_guid/processes" }, + packages: { href: "#{link_prefix}/v3/apps/app1_guid/packages" }, + current_droplet: { href: "#{link_prefix}/v3/apps/app1_guid/droplets/current" }, + droplets: { href: "#{link_prefix}/v3/apps/app1_guid/droplets" }, + tasks: { href: "#{link_prefix}/v3/apps/app1_guid/tasks" }, + start: { href: "#{link_prefix}/v3/apps/app1_guid/actions/start", method: 'POST' }, + stop: { href: "#{link_prefix}/v3/apps/app1_guid/actions/stop", method: 'POST' }, + clear_buildpack_cache: { href: "#{link_prefix}/v3/apps/app1_guid/actions/clear_buildpack_cache", method: 'POST' }, + revisions: { href: "#{link_prefix}/v3/apps/app1_guid/revisions" }, + deployed_revisions: { href: "#{link_prefix}/v3/apps/app1_guid/revisions/deployed" }, + features: { href: "#{link_prefix}/v3/apps/app1_guid/features" } + } + } + end + + let(:expected_codes_and_responses) do + h = Hash.new({ code: 200, response_object: app_model_response_object }.freeze) + h['org_auditor'] = { code: 404 } + h['org_billing_manager'] = { code: 404 } + h['no_role'] = { code: 404 } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + + context 'when the user has permission to view the app' do + before do + space.add_developer(user) + end + + it 'gets a specific app' do + get "/v3/apps/#{app_model.guid}", nil, user_header + expect(last_response.status).to eq(200) + + parsed_response = Oj.load(last_response.body) + expect(parsed_response).to be_a_response_like( + { + 'name' => 'my_app', + 'guid' => app_model.guid, + 'state' => 'STARTED', + 'created_at' => iso8601, + 'updated_at' => iso8601, + 'metadata' => { 'labels' => {}, 'annotations' => {} }, + 'lifecycle' => { + 'type' => 'buildpack', + 'data' => { + 'buildpacks' => ['bp-name'], + 'stack' => 'stack-name' + } + }, + 'relationships' => { + 'space' => { + 'data' => { + 'guid' => space.guid + } + }, + 'current_droplet' => { + 'data' => { + 'guid' => app_model.droplet_guid + } + } + }, + 'links' => { + 'self' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}" }, + 'processes' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/processes" }, + 'packages' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/packages" }, + 'environment_variables' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/environment_variables" }, + 'space' => { 'href' => "#{link_prefix}/v3/spaces/#{space.guid}" }, + 'current_droplet' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/droplets/current" }, + 'droplets' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/droplets" }, + 'tasks' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/tasks" }, + 'start' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/start", 'method' => 'POST' }, + 'stop' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/stop", 'method' => 'POST' }, + 'clear_buildpack_cache' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/clear_buildpack_cache", 'method' => 'POST' }, + 'revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/revisions" }, + 'deployed_revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/revisions/deployed" }, + 'features' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/features" } + } + } + ) + end + + it 'gets a specific app including space' do + get "/v3/apps/#{app_model.guid}?include=space", nil, user_header + expect(last_response.status).to eq(200) + + parsed_response = Oj.load(last_response.body) + expect(parsed_response).to be_a_response_like( + { + 'name' => 'my_app', + 'guid' => app_model.guid, + 'state' => 'STARTED', + 'created_at' => iso8601, + 'updated_at' => iso8601, + 'metadata' => { 'labels' => {}, 'annotations' => {} }, + 'lifecycle' => { + 'type' => 'buildpack', + 'data' => { + 'buildpacks' => ['bp-name'], + 'stack' => 'stack-name' + } + }, + 'relationships' => { + 'space' => { + 'data' => { + 'guid' => space.guid + } + }, + 'current_droplet' => { + 'data' => { + 'guid' => app_model.droplet_guid + } + } + }, + 'links' => { + 'self' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}" }, + 'processes' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/processes" }, + 'packages' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/packages" }, + 'environment_variables' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/environment_variables" }, + 'space' => { 'href' => "#{link_prefix}/v3/spaces/#{space.guid}" }, + 'current_droplet' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/droplets/current" }, + 'droplets' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/droplets" }, + 'tasks' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/tasks" }, + 'start' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/start", 'method' => 'POST' }, + 'stop' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/stop", 'method' => 'POST' }, + 'clear_buildpack_cache' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/clear_buildpack_cache", 'method' => 'POST' }, + 'revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/revisions" }, + 'deployed_revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/revisions/deployed" }, + 'features' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/features" } + }, + 'included' => { + 'spaces' => [{ + 'guid' => space.guid, + 'created_at' => iso8601, + 'updated_at' => iso8601, + 'name' => space.name, + 'relationships' => { + 'organization' => { + 'data' => { + 'guid' => space.organization.guid + } + }, + 'quota' => { + 'data' => nil + } + }, + 'metadata' => { + 'labels' => {}, + 'annotations' => {} + }, + 'links' => { + 'self' => { + 'href' => "#{link_prefix}/v3/spaces/#{space.guid}" + }, + 'organization' => { + 'href' => "#{link_prefix}/v3/organizations/#{space.organization.guid}" + }, + 'features' => { 'href' => %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}/features} }, + 'apply_manifest' => { + 'href' => "#{link_prefix}/v3/spaces/#{space.guid}/actions/apply_manifest", + 'method' => 'POST' + } + } + }] + } + } + ) + end + + it 'gets a specific app including space and org' do + get "/v3/apps/#{app_model.guid}?include=space.organization", nil, user_header + expect(last_response.status).to eq(200) + + parsed_response = Oj.load(last_response.body) + spaces = parsed_response['included']['spaces'] + orgs = parsed_response['included']['organizations'] + + expect(spaces).to be_present + expect(orgs[0]).to be_a_response_like( + { + 'guid' => org.guid, + 'created_at' => iso8601, + 'updated_at' => iso8601, + 'name' => org.name, + 'metadata' => { + 'labels' => {}, + 'annotations' => {} + }, + 'suspended' => false, + 'links' => { + 'self' => { + 'href' => "#{link_prefix}/v3/organizations/#{org.guid}" + }, + 'default_domain' => { + 'href' => "#{link_prefix}/v3/organizations/#{org.guid}/domains/default" + }, + 'domains' => { + 'href' => "#{link_prefix}/v3/organizations/#{org.guid}/domains" + }, + 'quota' => { + 'href' => "#{link_prefix}/v3/organization_quotas/#{org.quota_definition.guid}" + } + }, + 'relationships' => { 'quota' => { 'data' => { 'guid' => org.quota_definition.guid } } } + } + ) + end + end + end + + describe 'GET /v3/apps/:guid/env' do + before do + space.organization.add_user(user) + end + + context 'when getting an apps environment variables' do + let(:api_call) { ->(user_headers) { get "/v3/apps/#{app_model.guid}/env", nil, user_headers } } + let!(:app_model) do + VCAP::CloudController::AppModel.make( + :buildpack, + name: 'my_app', + guid: 'app1_guid', + space: space, + environment_variables: { 'unicorn' => 'horn' } + ) + end + + let(:app_model_response_object) do + { + environment_variables: app_model.environment_variables, + staging_env_json: {}, + running_env_json: {}, + system_env_json: { VCAP_SERVICES: {} }, + application_env_json: anything + } + end + let(:app_model_empty_system_env_response_object) do + { + environment_variables: app_model.environment_variables, + staging_env_json: {}, + running_env_json: {}, + system_env_json: { + redacted_message: '[PRIVATE DATA HIDDEN]' + }, + application_env_json: anything + } + end + + let(:expected_codes_and_responses) do + h = Hash.new({ code: 200, response_object: app_model_response_object }.freeze) + h['space_supporter'] = { code: 200, response_object: app_model_empty_system_env_response_object } + h['global_auditor'] = h['org_manager'] = h['space_manager'] = h['space_auditor'] = { code: 403 } + h['org_auditor'] = h['org_billing_manager'] = h['no_role'] = { code: 404 } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + + context 'when k8s service bindings are enabled' do + let(:app_model_response_object) do + r = super() + r[:system_env_json] = { SERVICE_BINDING_ROOT: '/etc/cf-service-bindings' } + r + end + + before do + app_model.update(service_binding_k8s_enabled: true) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + + context 'when file-based VCAP service bindings are enabled' do + let(:app_model_response_object) do + r = super() + r[:system_env_json] = { VCAP_SERVICES_FILE_PATH: '/etc/cf-service-bindings/vcap_services' } + r + end + + before do + app_model.update(file_based_vcap_services_enabled: true) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + end + + context 'when VCAP_SERVICES contains potentially sensitive information' do + before do + group = VCAP::CloudController::EnvironmentVariableGroup.staging + group.environment_json = { STAGING_ENV: 'staging_value' } + group.save + + group = VCAP::CloudController::EnvironmentVariableGroup.running + group.environment_json = { RUNNING_ENV: 'running_value' } + group.save + end + + let(:api_call) { ->(user_headers) { get "/v3/apps/#{app_model.guid}/env", nil, user_headers } } + let(:app_model) do + VCAP::CloudController::AppModel.make( + name: 'my_app', + space: space, + environment_variables: { 'unicorn' => 'horn' } + ) + end + let(:service_instance) do + VCAP::CloudController::ManagedServiceInstance.make( + space: space, + name: 'si-name', + tags: ['50% off'] + ) + end + let(:service_binding) do + VCAP::CloudController::ServiceBinding.make( + service_instance: service_instance, + app: app_model, + syslog_drain_url: 'https://syslog.example.com/drain', + credentials: { password: 'top-secret' } + ) + end + let(:expected_response) do + { + 'staging_env_json' => { + 'STAGING_ENV' => 'staging_value' + }, + 'running_env_json' => { + 'RUNNING_ENV' => 'running_value' + }, + 'environment_variables' => { + 'unicorn' => 'horn' + }, + 'system_env_json' => { + 'VCAP_SERVICES' => { + service_instance.service.label => [ + { + 'name' => 'si-name', + 'instance_guid' => service_instance.guid, + 'instance_name' => 'si-name', + 'binding_guid' => service_binding.guid, + 'binding_name' => nil, + 'credentials' => { 'password' => 'top-secret' }, + 'syslog_drain_url' => 'https://syslog.example.com/drain', + 'volume_mounts' => [], + 'label' => service_instance.service.label, + 'provider' => nil, + 'plan' => service_instance.service_plan.name, + 'tags' => ['50% off'] + } + ] + } + }, + 'application_env_json' => { + 'VCAP_APPLICATION' => { + 'cf_api' => "#{TestConfig.config[:external_protocol]}://#{TestConfig.config[:external_domain]}", + 'limits' => { + 'fds' => 16_384 + }, + 'application_name' => 'my_app', + 'application_uris' => [], + 'name' => 'my_app', + 'organization_id' => space.organization.guid, + 'organization_name' => space.organization.name, + 'space_id' => space.guid, + 'space_name' => space.name, + 'uris' => [], + 'users' => nil, + 'application_id' => app_model.guid + } + } + } + end + + let(:expected_response_system_env_redacted) do + { + 'staging_env_json' => { + 'STAGING_ENV' => 'staging_value' + }, + 'running_env_json' => { + 'RUNNING_ENV' => 'running_value' + }, + 'environment_variables' => { + 'unicorn' => 'horn' + }, + 'system_env_json' => { + 'redacted_message' => '[PRIVATE DATA HIDDEN]' + }, + 'application_env_json' => { + 'VCAP_APPLICATION' => { + 'cf_api' => "#{TestConfig.config[:external_protocol]}://#{TestConfig.config[:external_domain]}", + 'limits' => { + 'fds' => 16_384 + }, + 'application_name' => 'my_app', + 'application_uris' => [], + 'name' => 'my_app', + 'organization_id' => space.organization.guid, + 'organization_name' => space.organization.name, + 'space_id' => space.guid, + 'space_name' => space.name, + 'uris' => [], + 'users' => nil, + 'application_id' => app_model.guid + } + } + } + end + + let(:expected_codes_and_responses) do + h = Hash.new({ code: 403 }.freeze) + h['admin'] = h['admin_read_only'] = h['space_developer'] = { code: 200, response_object: expected_response } + h['space_supporter'] = { code: 200, response_object: expected_response_system_env_redacted } + h['org_auditor'] = h['org_billing_manager'] = h['no_role'] = { code: 404 } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + + 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_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS do + let(:expected_codes_and_responses) do + h = Hash.new({ code: 403 }.freeze) + h['admin'] = h['admin_read_only'] = { code: 200, response_object: expected_response } + h['org_auditor'] = h['org_billing_manager'] = h['no_role'] = { code: 404 } + h + end + end + end + end + end + + describe 'GET /v3/apps/:guid/builds' do + let(:app_model) { VCAP::CloudController::AppModel.make(space: space, name: 'my-app') } + let(:build) do + VCAP::CloudController::BuildModel.make( + package: package, + app: app_model, + staging_memory_in_mb: 123, + staging_disk_in_mb: 456, + staging_log_rate_limit: 789, + created_by_user_name: 'bob the builder', + created_by_user_guid: user.guid, + created_by_user_email: 'bob@loblaw.com' + ) + end + let!(:second_build) do + VCAP::CloudController::BuildModel.make( + package: package, + app: app_model, + staging_memory_in_mb: 123, + staging_disk_in_mb: 456, + staging_log_rate_limit: 789, + created_at: build.created_at - 1.day, + created_by_user_name: 'bob the builder', + created_by_user_guid: user.guid, + created_by_user_email: 'bob@loblaw.com' + ) + end + let(:package) { VCAP::CloudController::PackageModel.make(app_guid: app_model.guid) } + let(:droplet) do + VCAP::CloudController::DropletModel.make( + state: VCAP::CloudController::DropletModel::STAGED_STATE, + package_guid: package.guid, + build: build + ) + end + let(:second_droplet) do + VCAP::CloudController::DropletModel.make( + state: VCAP::CloudController::DropletModel::STAGED_STATE, + package_guid: package.guid, + build: second_build + ) + end + let(:body) do + { + lifecycle: { + type: 'buildpack', + data: { + buildpacks: ['http://github.com/myorg/awesome-buildpack'], + stack: 'cflinuxfs4' + } + } + } + end + + describe 'permissions' do + let(:api_call) do + ->(headers) { get "/v3/apps/#{app_model.guid}/builds", nil, headers } + end + let(:expected_codes_and_responses) do + h = Hash.new({ code: 200, response_guids: [build.guid, second_build.guid] }.freeze) + h['org_auditor'] = { code: 404 } + h['org_billing_manager'] = { code: 404 } + h['no_role'] = { code: 404 } + h + end + + it_behaves_like 'permissions for list endpoint', ALL_PERMISSIONS + end + + describe 'as a developer' do + let(:staging_message) { VCAP::CloudController::BuildCreateMessage.new(body) } + let(:per_page) { 2 } + let(:order_by) { '-created_at' } + + before do + space.organization.add_user(user) + space.add_developer(user) + VCAP::CloudController::BuildpackLifecycle.new(package, staging_message).create_lifecycle_data_model(build) + VCAP::CloudController::BuildpackLifecycle.new(package, staging_message).create_lifecycle_data_model(second_build) + build.update(state: droplet.state, error_description: droplet.error_description) + second_build.update(state: second_droplet.state, error_description: second_droplet.error_description) + end + + it 'lists the builds for app' do + get "v3/apps/#{app_model.guid}/builds?order_by=#{order_by}&per_page=#{per_page}", nil, user_header + + parsed_response = Oj.load(last_response.body) + + expect(last_response.status).to eq(200) + expect(parsed_response['resources']).to include(hash_including('guid' => build.guid)) + expect(parsed_response['resources']).to include(hash_including('guid' => second_build.guid)) + expect(parsed_response).to be_a_response_like({ + 'pagination' => { + 'total_results' => 2, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/builds?order_by=#{order_by}&page=1&per_page=2" }, + 'last' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/builds?order_by=#{order_by}&page=1&per_page=2" }, + 'next' => nil, + 'previous' => nil + }, + 'resources' => [ + { + 'guid' => build.guid, + 'created_at' => iso8601, + 'updated_at' => iso8601, + 'state' => 'STAGED', + 'error' => nil, + 'staging_memory_in_mb' => 123, + 'staging_disk_in_mb' => 456, + 'staging_log_rate_limit_bytes_per_second' => 789, + 'lifecycle' => { + 'type' => 'buildpack', + 'data' => { + 'buildpacks' => ['http://github.com/myorg/awesome-buildpack'], + 'stack' => 'cflinuxfs4' + } + }, + 'package' => { 'guid' => package.guid }, + 'droplet' => { + 'guid' => droplet.guid + }, + 'relationships' => { 'app' => { 'data' => { 'guid' => app_model.guid } } }, + 'metadata' => { 'labels' => {}, 'annotations' => {} }, + 'links' => { + 'self' => { 'href' => "#{link_prefix}/v3/builds/#{build.guid}" }, + 'app' => { 'href' => "#{link_prefix}/v3/apps/#{package.app.guid}" }, + 'droplet' => { 'href' => "#{link_prefix}/v3/droplets/#{droplet.guid}" } + }, + 'created_by' => { 'guid' => user.guid, 'name' => 'bob the builder', 'email' => 'bob@loblaw.com' } + }, + { + 'guid' => second_build.guid, + 'created_at' => iso8601, + 'updated_at' => iso8601, + 'state' => 'STAGED', + 'error' => nil, + 'staging_memory_in_mb' => 123, + 'staging_disk_in_mb' => 456, + 'staging_log_rate_limit_bytes_per_second' => 789, + 'lifecycle' => { + 'type' => 'buildpack', + 'data' => { + 'buildpacks' => ['http://github.com/myorg/awesome-buildpack'], + 'stack' => 'cflinuxfs4' + } + }, + 'package' => { 'guid' => package.guid }, + 'droplet' => { + 'guid' => second_droplet.guid + }, + 'relationships' => { 'app' => { 'data' => { 'guid' => app_model.guid } } }, + 'metadata' => { 'labels' => {}, 'annotations' => {} }, + 'links' => { + 'self' => { 'href' => "#{link_prefix}/v3/builds/#{second_build.guid}" }, + 'app' => { 'href' => "#{link_prefix}/v3/apps/#{package.app.guid}" }, + 'droplet' => { 'href' => "#{link_prefix}/v3/droplets/#{second_droplet.guid}" } + }, + 'created_by' => { 'guid' => user.guid, 'name' => 'bob the builder', 'email' => 'bob@loblaw.com' } + } + ] + }) + end + + it_behaves_like 'list_endpoint_with_common_filters' do + let(:resource_klass) { VCAP::CloudController::BuildModel } + let(:additional_resource_params) { { app: app_model } } + let(:api_call) do + ->(headers, filters) { get "/v3/apps/#{app_model.guid}/builds?#{filters}", nil, headers } + end + let(:headers) { admin_header } + end + + it 'filters on label_selector' do + VCAP::CloudController::BuildLabelModel.make(key_name: 'fruit', value: 'strawberry', build: build) + + get "/v3/apps/#{app_model.guid}/builds?label_selector=fruit=strawberry", {}, user_header + + expect(last_response.status).to eq(200) + expect(parsed_response['resources'].count).to eq(1) + expect(parsed_response['resources'][0]['guid']).to eq(build.guid) + end + end + end + + describe 'GET /v3/apps/:guid/ssh_enabled' do + before do + space.organization.add_user(user) + end + + context 'when getting an apps ssh_enabled value' do + let(:api_call) { ->(user_headers) { get "/v3/apps/#{app_model.guid}/ssh_enabled", nil, user_headers } } + let!(:app_model) do + VCAP::CloudController::AppModel.make( + :buildpack, + name: 'my_app', + guid: 'app1_guid', + space: space + ) + end + + let(:expected_codes_and_responses) do + h = Hash.new({ code: 200 }.freeze) + h['org_auditor'] = { code: 404 } + h['org_billing_manager'] = { code: 404 } + h['no_role'] = { code: 404 } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + end + + describe 'DELETE /v3/apps/guid' do + let!(:app_model) { VCAP::CloudController::AppModel.make(name: 'app_name', space: space) } + let!(:package) { VCAP::CloudController::PackageModel.make(app: app_model) } + let!(:droplet) { VCAP::CloudController::DropletModel.make(package: package, app: app_model) } + let!(:process) { VCAP::CloudController::ProcessModel.make(app: app_model) } + let!(:deployment) { VCAP::CloudController::DeploymentModel.make(app: app_model) } + let(:user_email) { nil } + + it 'deletes an App' do + space.organization.add_user(user) + space.add_developer(user) + delete "/v3/apps/#{app_model.guid}", nil, user_header + + expect(last_response.status).to eq(202) + expect(last_response.headers['Location']).to match(%r{/v3/jobs/#{VCAP::CloudController::PollableJobModel.last.guid}}) + + Delayed::Worker.new.work_off + + expect(app_model).not_to exist + expect(package).not_to exist + expect(droplet).not_to exist + expect(process).not_to exist + expect(deployment).not_to exist + + event = VCAP::CloudController::Event.last(2).first + expect(event.values).to include({ + type: 'audit.app.delete-request', + actee: app_model.guid, + actee_type: 'app', + actee_name: 'app_name', + actor: user.guid, + actor_type: 'user', + actor_name: '', + actor_username: user_name, + space_guid: space.guid, + organization_guid: space.organization.guid + }) + end + + context 'permissions for deleting an app' do + let(:api_call) { ->(user_headers) { delete "/v3/apps/#{app_model.guid}", nil, user_headers } } + let(:expected_codes_and_responses) do + h = Hash.new({ code: 202 }.freeze) + %w[admin_read_only global_auditor org_manager space_auditor space_manager space_supporter].each do |r| + h[r] = { code: 403, errors: CF_NOT_AUTHORIZED } + end + h['org_auditor'] = { code: 404 } + h['org_billing_manager'] = { code: 404 } + h['no_role'] = { code: 404 } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + + context 'when organization is suspended' do + let(:expected_codes_and_responses) do + h = super() + h['space_developer'] = { code: 403, errors: CF_ORG_SUSPENDED } + h + end + + before do + org.update(status: VCAP::CloudController::Organization::SUSPENDED) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + end + + context 'deleting metadata' do + before do + space.organization.add_user(user) + space.add_developer(user) + end + + it_behaves_like 'resource with metadata' do + let(:resource) { app_model } + let(:api_call) do + -> { delete "/v3/apps/#{resource.guid}", nil, user_header } + end + end + end + end + + describe 'PATCH /v3/apps/:guid' do + let(:app_model) do + VCAP::CloudController::AppModel.make( + :buildpack, + name: 'original_name', + space: space, + environment_variables: { 'ORIGINAL' => 'ENVAR' }, + desired_state: 'STOPPED' + ) + end + let!(:web_process) { VCAP::CloudController::ProcessModel.make(app: app_model, state: VCAP::CloudController::ProcessModel::STOPPED) } + let(:stack) { VCAP::CloudController::Stack.make(name: 'redhat') } + + let(:update_request) do + { + name: 'new-name', + lifecycle: { + type: 'buildpack', + data: { + buildpacks: ['http://gitwheel.org/my-app'], + stack: stack.name + } + }, + metadata: { + labels: { + 'release' => 'stable', + 'code.cloudfoundry.org/cloud_controller_ng' => 'awesome', + 'delete-me' => nil + }, + annotations: { + 'contacts' => 'Bill tel(1111111) email(bill@fixme), Bob tel(222222) pager(3333333#555) email(bob@fixme)', + 'anno1' => 'new-value', + 'please' => nil + } + } + } + end + + let(:expected_response_object) do + { + 'name' => 'new-name', + 'guid' => app_model.guid, + 'state' => 'STOPPED', + 'lifecycle' => { + 'type' => 'buildpack', + 'data' => { + 'buildpacks' => ['http://gitwheel.org/my-app'], + 'stack' => stack.name + } + }, + 'relationships' => { + 'space' => { + 'data' => { + 'guid' => space.guid + } + }, + 'current_droplet' => { + 'data' => { + 'guid' => nil + } + } + }, + 'created_at' => iso8601, + 'updated_at' => iso8601, + 'metadata' => { + 'labels' => { + 'release' => 'stable', + 'code.cloudfoundry.org/cloud_controller_ng' => 'awesome' + }, + 'annotations' => { + 'contacts' => 'Bill tel(1111111) email(bill@fixme), Bob tel(222222) pager(3333333#555) email(bob@fixme)', + 'anno1' => 'new-value' + } + }, + 'links' => { + 'self' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}" }, + 'processes' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/processes" }, + 'packages' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/packages" }, + 'environment_variables' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/environment_variables" }, + 'space' => { 'href' => "#{link_prefix}/v3/spaces/#{space.guid}" }, + 'current_droplet' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/droplets/current" }, + 'droplets' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/droplets" }, + 'tasks' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/tasks" }, + 'start' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/start", 'method' => 'POST' }, + 'stop' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/stop", 'method' => 'POST' }, + 'clear_buildpack_cache' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/clear_buildpack_cache", 'method' => 'POST' }, + 'revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/revisions" }, + 'deployed_revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/revisions/deployed" }, + 'features' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/features" } + } + } + end + + before do + VCAP::CloudController::AppLabelModel.make( + resource_guid: app_model.guid, + key_name: 'delete-me', + value: 'yes' + ) + + VCAP::CloudController::AppAnnotationModel.make( + resource_guid: app_model.guid, + key_name: 'anno1', + value: 'original-value' + ) + + VCAP::CloudController::AppAnnotationModel.make( + resource_guid: app_model.guid, + key_name: 'please', + value: 'delete this' + ) + end + + it 'updates an app' do + space.organization.add_user(user) + space.add_developer(user) + expect_any_instance_of(VCAP::CloudController::Diego::Runner).not_to receive(:update_metric_tags) + patch "/v3/apps/#{app_model.guid}", update_request.to_json, user_header + expect(last_response.status).to eq(200) + + app_model.reload + + parsed_response = Oj.load(last_response.body) + expect(parsed_response).to be_a_response_like(expected_response_object) + + event = VCAP::CloudController::Event.last + expect(event.values).to include({ + type: 'audit.app.update', + actee: app_model.guid, + actee_type: 'app', + actee_name: 'new-name', + actor: user.guid, + actor_type: 'user', + actor_name: user_email, + actor_username: user_name, + space_guid: space.guid, + organization_guid: space.organization.guid + }) + metadata_request = { + 'name' => 'new-name', + 'lifecycle' => { + 'type' => 'buildpack', + 'data' => { + 'buildpacks' => ['http://gitwheel.org/my-app'], + 'stack' => stack.name + } + }, + 'metadata' => { + 'labels' => { + 'release' => 'stable', + 'code.cloudfoundry.org/cloud_controller_ng' => 'awesome', + 'delete-me' => nil + }, + 'annotations' => { + 'contacts' => 'Bill tel(1111111) email(bill@fixme), Bob tel(222222) pager(3333333#555) email(bob@fixme)', + 'anno1' => 'new-value', + 'please' => nil + } + } + } + expect(event.metadata['request']).to eq(metadata_request) + end + + context 'when the app has a process that is started' do + let!(:web_process) { VCAP::CloudController::ProcessModel.make(app: app_model, state: VCAP::CloudController::ProcessModel::STARTED) } + + before do + app_model.desired_state = VCAP::CloudController::ProcessModel::STARTED + end + + it 'notifies diego that an app has been renamed' do + space.organization.add_user(user) + space.add_developer(user) + expect_any_instance_of(VCAP::CloudController::Diego::Runner).to receive(:update_metric_tags) + patch "/v3/apps/#{app_model.guid}", update_request.to_json, user_header + expect(last_response.status).to eq(200) + end + end + + context 'permissions for updating an app' do + let(:api_call) { ->(user_headers) { patch "/v3/apps/#{app_model.guid}", update_request.to_json, user_headers } } + let(:expected_codes_and_responses) do + h = Hash.new({ code: 200, response_object: expected_response_object }.freeze) + %w[admin_read_only global_auditor org_manager space_auditor space_manager space_supporter].each do |r| + h[r] = { code: 403, errors: CF_NOT_AUTHORIZED } + end + h['org_auditor'] = { code: 404 } + h['org_billing_manager'] = { code: 404 } + h['no_role'] = { code: 404 } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + + context 'when organization is suspended' do + let(:expected_codes_and_responses) do + h = super() + h['space_developer'] = { code: 403, errors: CF_ORG_SUSPENDED } + h + end + + before do + org.update(status: VCAP::CloudController::Organization::SUSPENDED) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + end + + context 'telemetry' do + before do + space.organization.add_user(user) + space.add_developer(user) + end + + it 'logs the required fields when the app gets updated' do + Timecop.freeze do + expected_json = { + 'telemetry-source' => 'cloud_controller_ng', + 'telemetry-time' => Time.now.to_datetime.rfc3339, + 'update-app' => { + 'api-version' => 'v3', + 'app-id' => OpenSSL::Digest::SHA256.hexdigest(app_model.guid), + 'user-id' => OpenSSL::Digest::SHA256.hexdigest(user.guid) + } + } + expect_any_instance_of(ActiveSupport::Logger).to receive(:info).with(Oj.dump(expected_json)) + + patch "/v3/apps/#{app_model.guid}", update_request.to_json, user_header + expect(last_response.status).to eq(200), last_response.body + end + end + end + end + + describe 'POST /v3/apps/:guid/actions/start' do + let(:stack) { VCAP::CloudController::Stack.make(name: 'stack-name') } + let(:app_model) do + VCAP::CloudController::AppModel.make( + :buildpack, + name: 'app-name', + space: space, + desired_state: 'STOPPED' + ) + end + + context 'app lifecycle is buildpack' do + let!(:droplet) do + VCAP::CloudController::DropletModel.make( + :buildpack, + app: app_model, + state: VCAP::CloudController::DropletModel::STAGED_STATE + ) + end + + before do + app_model.lifecycle_data.buildpacks = ['http://example.com/git'] + app_model.lifecycle_data.stack = stack.name + app_model.lifecycle_data.save + app_model.droplet = droplet + app_model.save + end + + context 'starting an app' do + let(:api_call) { ->(user_headers) { post "/v3/apps/#{app_model.guid}/actions/start", nil, user_headers } } + let(:app_start_response_object) do + { + 'name' => 'app-name', + 'guid' => app_model.guid, + 'state' => 'STARTED', + 'created_at' => iso8601, + 'updated_at' => iso8601, + 'metadata' => { 'labels' => {}, 'annotations' => {} }, + 'lifecycle' => { + 'type' => 'buildpack', + 'data' => { + 'buildpacks' => ['http://example.com/git'], + 'stack' => 'stack-name' + } + }, + 'relationships' => { + 'space' => { + 'data' => { + 'guid' => space.guid + } + }, + 'current_droplet' => { + 'data' => { + 'guid' => droplet.guid + } + } + }, + 'links' => { + 'self' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}" }, + 'processes' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/processes" }, + 'packages' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/packages" }, + 'environment_variables' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/environment_variables" }, + 'space' => { 'href' => "#{link_prefix}/v3/spaces/#{space.guid}" }, + 'current_droplet' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/droplets/current" }, + 'droplets' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/droplets" }, + 'tasks' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/tasks" }, + 'start' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/start", 'method' => 'POST' }, + 'stop' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/stop", 'method' => 'POST' }, + 'clear_buildpack_cache' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/clear_buildpack_cache", 'method' => 'POST' }, + 'revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/revisions" }, + 'deployed_revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/revisions/deployed" }, + 'features' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/features" } + } + } + end + + let(:expected_codes_and_responses) do + h = Hash.new({ code: 403, errors: CF_NOT_AUTHORIZED }.freeze) + h['no_role'] = { code: 404 } + h['org_auditor'] = { code: 404 } + h['org_billing_manager'] = { code: 404 } + h['admin'] = { + code: 200, + response_object: app_start_response_object + } + h['space_supporter'] = { + code: 200, + response_object: app_start_response_object + } + h['space_developer'] = { + code: 200, + response_object: app_start_response_object + } + h + end + + before do + space.organization.add_user(user) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + + context 'when organization is suspended' do + let(:expected_codes_and_responses) do + h = super() + %w[space_supporter space_developer].each { |r| h[r] = { code: 403, errors: CF_ORG_SUSPENDED } } + h + end + + before do + org.update(status: VCAP::CloudController::Organization::SUSPENDED) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + + describe 'limiting the application log rates' do + let(:log_rate_limit) { -1 } + let(:space_log_rate_limit) { -1 } + let(:org_log_rate_limit) { -1 } + let(:org_quota_definition) { VCAP::CloudController::QuotaDefinition.make(log_rate_limit: org_log_rate_limit) } + let(:org) { VCAP::CloudController::Organization.make(quota_definition: org_quota_definition) } + let(:space_quota_definition) { VCAP::CloudController::SpaceQuotaDefinition.make(organization: org, log_rate_limit: space_log_rate_limit) } + let(:space) { VCAP::CloudController::Space.make(organization: org, space_quota_definition: space_quota_definition) } + let!(:process_model) { VCAP::CloudController::ProcessModel.make(app: app_model, log_rate_limit: log_rate_limit) } + let(:app_model) do + VCAP::CloudController::AppModel.make( + :buildpack, + name: 'app-name', + space: space, + desired_state: 'STOPPED' + ) + end + let(:droplet) { VCAP::CloudController::DropletModel.make(app: app_model, process_types: { web: 'webby' }) } + + before do + app_model.update(droplet_guid: droplet.guid) + end + + describe 'space quotas' do + context 'when both the space and the app do not specify a log rate limit' do + let(:log_rate_limit) { -1 } + let(:space_log_rate_limit) { -1 } + + it 'starts the app successfully' do + post "/v3/apps/#{app_model.guid}/actions/start", nil, admin_header + + expect(last_response.status).to eq(200) + end + end + + context "when the app fits in the space's log rate limit" do + let(:log_rate_limit) { 199 } + let(:space_log_rate_limit) { 200 } + + it 'starts the app successfully' do + post "/v3/apps/#{app_model.guid}/actions/start", nil, admin_header + + expect(last_response.status).to eq(200) + end + end + + context "when the app's log rate limit is unspecified, but the space specifies a log rate limit" do + let(:log_rate_limit) { -1 } + let(:space_log_rate_limit) { 200 } + + it 'fails to start the app' do + post "/v3/apps/#{app_model.guid}/actions/start", nil, admin_header + + expect(last_response.status).to eq(422) + expect(last_response).to have_error_message("log_rate_limit cannot be unlimited in space '#{space.name}'.") + end + end + + context "when the app's log rate limit is larger than the limit specified by the space" do + let(:log_rate_limit) { 201 } + let(:space_log_rate_limit) { 200 } + + it 'fails to start the app' do + post "/v3/apps/#{app_model.guid}/actions/start", nil, admin_header + + expect(last_response.status).to eq(422) + expect(last_response).to have_error_message('log_rate_limit exceeds space log rate quota') + end + end + + context "when the space's quota is more strict that the org's quota, the space quota controls" do + let(:log_rate_limit) { 201 } + let(:space_log_rate_limit) { 200 } + let(:org_log_rate_limit) { 201 } + + it 'fails to start the app' do + post "/v3/apps/#{app_model.guid}/actions/start", nil, admin_header + + expect(last_response.status).to eq(422) + expect(last_response).to have_error_message('log_rate_limit exceeds space log rate quota') + end + end + end + + describe 'organization quotas' do + context 'when both the org and the app do not specify a log rate limit' do + let(:log_rate_limit) { -1 } + let(:org_log_rate_limit) { -1 } + + it 'starts the app successfully' do + post "/v3/apps/#{app_model.guid}/actions/start", nil, admin_header + + expect(last_response.status).to eq(200) + end + end + + context "when the app fits in the org's log rate limit" do + let(:log_rate_limit) { 199 } + let(:org_log_rate_limit) { 200 } + + it 'starts the app successfully' do + post "/v3/apps/#{app_model.guid}/actions/start", nil, admin_header + + expect(last_response.status).to eq(200) + end + end + + context "when the app's log rate limit is unspecified, but the org specifies a log rate limit" do + let(:log_rate_limit) { -1 } + let(:org_log_rate_limit) { 200 } + + it 'fails to start the app' do + post "/v3/apps/#{app_model.guid}/actions/start", nil, admin_header + + expect(last_response.status).to eq(422) + expect(last_response).to have_error_message("log_rate_limit cannot be unlimited in organization '#{org.name}'.") + end + end + + context "when the app's log rate limit is larger than the limit specified by the org" do + let(:log_rate_limit) { 201 } + let(:org_log_rate_limit) { 200 } + + it 'fails to start the app' do + post "/v3/apps/#{app_model.guid}/actions/start", nil, admin_header + + expect(last_response.status).to eq(422) + expect(last_response).to have_error_message('log_rate_limit exceeds organization log rate quota') + end + end + + context "when the org's quota is more strict that the space's quota, the org quota controls" do + let(:log_rate_limit) { 201 } + let(:space_log_rate_limit) { 202 } + let(:org_log_rate_limit) { 200 } + + it 'fails to start the app' do + post "/v3/apps/#{app_model.guid}/actions/start", nil, admin_header + + expect(last_response.status).to eq(422) + expect(last_response).to have_error_message('log_rate_limit exceeds organization log rate quota') + end + end + end + end + end + + context 'events' do + before do + space.organization.add_user(user) + space.add_developer(user) + end + + it 'issues the required events when the app starts' do + post "/v3/apps/#{app_model.guid}/actions/start", nil, user_header + + event = VCAP::CloudController::Event.last + expect(event.values).to include({ + type: 'audit.app.start', + actee: app_model.guid, + actee_type: 'app', + actee_name: 'app-name', + actor: user.guid, + actor_type: 'user', + actor_name: user_email, + actor_username: user_name, + space_guid: space.guid, + organization_guid: space.organization.guid + }) + end + end + + context 'telemetry' do + before do + space.organization.add_user(user) + space.add_developer(user) + end + + it 'logs the required fields when the app starts' do + Timecop.freeze do + expected_json = { + 'telemetry-source' => 'cloud_controller_ng', + 'telemetry-time' => Time.now.to_datetime.rfc3339, + 'start-app' => { + 'api-version' => 'v3', + 'app-id' => OpenSSL::Digest::SHA256.hexdigest(app_model.guid), + 'user-id' => OpenSSL::Digest::SHA256.hexdigest(user.guid) + } + } + expect_any_instance_of(ActiveSupport::Logger).to receive(:info).with(Oj.dump(expected_json)) + post "/v3/apps/#{app_model.guid}/actions/start", nil, user_header + + expect(last_response.status).to eq(200), last_response.body + end + end + end + end + + describe 'when there is a new desired droplet and revision feature is turned on' do + let(:droplet) do + VCAP::CloudController::DropletModel.make( + app: app_model, + process_types: { web: 'rackup' }, + state: VCAP::CloudController::DropletModel::STAGED_STATE, + package: VCAP::CloudController::PackageModel.make + ) + end + + before do + space.organization.add_user(user) + space.add_developer(user) + app_model.update(revisions_enabled: true) + end + + it 'creates a new revision' do + expect do + patch "/v3/apps/#{app_model.guid}/relationships/current_droplet", { data: { guid: droplet.guid } }.to_json, user_header + expect(last_response.status).to eq(200) + end.not_to(change(VCAP::CloudController::RevisionModel, :count)) + + expect do + post "/v3/apps/#{app_model.guid}/actions/start", nil, user_header + expect(last_response.status).to eq(200), last_response.body + end.to change(VCAP::CloudController::RevisionModel, :count).by(1) + end + end + end + + describe 'POST /v3/apps/:guid/actions/stop' do + let(:stack) { VCAP::CloudController::Stack.make(name: 'stack-name') } + let(:app_model) do + VCAP::CloudController::AppModel.make( + :buildpack, + name: 'app-name', + space: space, + desired_state: 'STARTED' + ) + end + let!(:droplet) do + VCAP::CloudController::DropletModel.make(:buildpack, + app: app_model, + state: VCAP::CloudController::DropletModel::STAGED_STATE) + end + + before do + app_model.lifecycle_data.buildpacks = ['http://example.com/git'] + app_model.lifecycle_data.stack = stack.name + app_model.lifecycle_data.save + app_model.droplet = droplet + app_model.save + end + + context 'stopping an app' do + let(:api_call) { ->(user_headers) { post "/v3/apps/#{app_model.guid}/actions/stop", nil, user_headers } } + let(:app_stop_response_object) do + { + 'name' => 'app-name', + 'guid' => app_model.guid, + 'state' => 'STOPPED', + 'created_at' => iso8601, + 'updated_at' => iso8601, + 'metadata' => { 'labels' => {}, 'annotations' => {} }, + 'lifecycle' => { + 'type' => 'buildpack', + 'data' => { + 'buildpacks' => ['http://example.com/git'], + 'stack' => 'stack-name' + } + }, + 'relationships' => { + 'space' => { + 'data' => { + 'guid' => space.guid + } + }, + 'current_droplet' => { + 'data' => { + 'guid' => droplet.guid + } + } + }, + 'links' => { + 'self' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}" }, + 'processes' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/processes" }, + 'packages' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/packages" }, + 'environment_variables' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/environment_variables" }, + 'space' => { 'href' => "#{link_prefix}/v3/spaces/#{space.guid}" }, + 'current_droplet' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/droplets/current" }, + 'droplets' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/droplets" }, + 'tasks' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/tasks" }, + 'start' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/start", 'method' => 'POST' }, + 'stop' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/stop", 'method' => 'POST' }, + 'clear_buildpack_cache' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/clear_buildpack_cache", 'method' => 'POST' }, + 'revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/revisions" }, + 'deployed_revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/revisions/deployed" }, + 'features' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/features" } + } + } + end + + let(:expected_codes_and_responses) do + h = Hash.new({ code: 403, errors: CF_NOT_AUTHORIZED }.freeze) + h['no_role'] = { code: 404 } + h['org_auditor'] = { code: 404 } + h['org_billing_manager'] = { code: 404 } + h['admin'] = { + code: 200, + response_object: app_stop_response_object + } + h['space_supporter'] = { + code: 200, + response_object: app_stop_response_object + } + h['space_developer'] = { + code: 200, + response_object: app_stop_response_object + } + h + end + + before do + space.organization.add_user(user) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + + context 'when organization is suspended' do + let(:expected_codes_and_responses) do + h = super() + %w[space_supporter space_developer].each { |r| h[r] = { code: 403, errors: CF_ORG_SUSPENDED } } + h + end + + before do + org.update(status: VCAP::CloudController::Organization::SUSPENDED) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + end + + context 'events' do + before do + space.organization.add_user(user) + space.add_developer(user) + end + + it 'issues the required events when the app stops' do + post "/v3/apps/#{app_model.guid}/actions/stop", nil, user_header + + event = VCAP::CloudController::Event.last + expect(event.values).to include({ + type: 'audit.app.stop', + actee: app_model.guid, + actee_type: 'app', + actee_name: 'app-name', + actor: user.guid, + actor_type: 'user', + actor_name: user_email, + actor_username: user_name, + space_guid: space.guid, + organization_guid: space.organization.guid + }) + end + end + + context 'telemetry' do + before do + space.organization.add_user(user) + space.add_developer(user) + end + + it 'logs the required fields when the app stops' do + Timecop.freeze do + expected_json = { + 'telemetry-source' => 'cloud_controller_ng', + 'telemetry-time' => Time.now.to_datetime.rfc3339, + 'stop-app' => { + 'api-version' => 'v3', + 'app-id' => OpenSSL::Digest::SHA256.hexdigest(app_model.guid), + 'user-id' => OpenSSL::Digest::SHA256.hexdigest(user.guid) + } + } + expect_any_instance_of(ActiveSupport::Logger).to receive(:info).with(Oj.dump(expected_json)) + + post "/v3/apps/#{app_model.guid}/actions/stop", nil, user_header + + expect(last_response.status).to eq(200), last_response.body + end + end + end + end + + describe 'POST /v3/apps/:guid/actions/restart' do + let(:stack) { VCAP::CloudController::Stack.make(name: 'stack-name') } + let(:app_model) do + VCAP::CloudController::AppModel.make( + :buildpack, + name: 'app-name', + space: space, + desired_state: 'STARTED' + ) + end + + context 'app lifecycle is buildpack' do + let!(:droplet) do + VCAP::CloudController::DropletModel.make( + :buildpack, + app: app_model, + state: VCAP::CloudController::DropletModel::STAGED_STATE + ) + end + + before do + app_model.lifecycle_data.buildpacks = ['http://example.com/git'] + app_model.lifecycle_data.stack = stack.name + app_model.lifecycle_data.save + app_model.droplet = droplet + app_model.save + end + + context 'restarting an app' do + let(:api_call) { ->(user_headers) { post "/v3/apps/#{app_model.guid}/actions/restart", nil, user_headers } } + let(:app_restart_response_object) do + { + 'name' => 'app-name', + 'guid' => app_model.guid, + 'state' => 'STARTED', + 'created_at' => iso8601, + 'updated_at' => iso8601, + 'metadata' => { 'labels' => {}, 'annotations' => {} }, + 'lifecycle' => { + 'type' => 'buildpack', + 'data' => { + 'buildpacks' => ['http://example.com/git'], + 'stack' => 'stack-name' + } + }, + 'relationships' => { + 'space' => { + 'data' => { + 'guid' => space.guid + } + }, + 'current_droplet' => { + 'data' => { + 'guid' => droplet.guid + } + } + }, + 'links' => { + 'self' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}" }, + 'processes' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/processes" }, + 'packages' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/packages" }, + 'environment_variables' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/environment_variables" }, + 'space' => { 'href' => "#{link_prefix}/v3/spaces/#{space.guid}" }, + 'current_droplet' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/droplets/current" }, + 'droplets' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/droplets" }, + 'tasks' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/tasks" }, + 'start' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/start", 'method' => 'POST' }, + 'stop' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/stop", 'method' => 'POST' }, + 'clear_buildpack_cache' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/clear_buildpack_cache", 'method' => 'POST' }, + 'revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/revisions" }, + 'deployed_revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/revisions/deployed" }, + 'features' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/features" } + } + } + end + + let(:expected_codes_and_responses) do + h = Hash.new({ code: 403, errors: CF_NOT_AUTHORIZED }.freeze) + h['no_role'] = { code: 404 } + h['org_auditor'] = { code: 404 } + h['org_billing_manager'] = { code: 404 } + h['admin'] = { + code: 200, + response_object: app_restart_response_object + } + h['space_supporter'] = { + code: 200, + response_object: app_restart_response_object + } + h['space_developer'] = { + code: 200, + response_object: app_restart_response_object + } + h + end + + before do + space.organization.add_user(user) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + + context 'when organization is suspended' do + let(:expected_codes_and_responses) do + h = super() + %w[space_supporter space_developer].each { |r| h[r] = { code: 403, errors: CF_ORG_SUSPENDED } } + h + end + + before do + org.update(status: VCAP::CloudController::Organization::SUSPENDED) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + end + + context 'telemetry' do + before do + space.organization.add_user(user) + space.add_developer(user) + end + + it 'logs the required fields when the app is restarted' do + Timecop.freeze do + expected_json = { + 'telemetry-source' => 'cloud_controller_ng', + 'telemetry-time' => Time.now.to_datetime.rfc3339, + 'restart-app' => { + 'api-version' => 'v3', + 'app-id' => OpenSSL::Digest::SHA256.hexdigest(app_model.guid), + 'user-id' => OpenSSL::Digest::SHA256.hexdigest(user.guid) + } + } + expect_any_instance_of(ActiveSupport::Logger).to receive(:info).with(Oj.dump(expected_json)) + + post "/v3/apps/#{app_model.guid}/actions/restart", nil, user_header + + expect(last_response.status).to eq(200), last_response.body + end + end + end + end + end + + describe 'GET /v3/apps/:guid/relationships/current_droplet' do + let(:api_call) { ->(user_headers) { get "/v3/apps/#{droplet_model.app_guid}/relationships/current_droplet", nil, user_headers } } + let(:app_model) { VCAP::CloudController::AppModel.make(space_guid: space.guid) } + let!(:droplet_model) { VCAP::CloudController::DropletModel.make(app_guid: app_model.guid) } + let(:expected_response) do + { + 'data' => { + 'guid' => droplet_model.guid + }, + 'links' => { + 'self' => { 'href' => "#{link_prefix}/v3/apps/#{droplet_model.app_guid}/relationships/current_droplet" }, + 'related' => { 'href' => "#{link_prefix}/v3/apps/#{droplet_model.app_guid}/droplets/current" } + } + } + end + + let(:expected_codes_and_responses) do + h = Hash.new({ code: 200, response_object: expected_response }.freeze) + h['no_role'] = { code: 404 } + h['org_billing_manager'] = { code: 404 } + h['org_auditor'] = { code: 404 } + h + end + + before do + app_model.droplet_guid = droplet_model.guid + app_model.save + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + + describe 'GET /v3/apps/:guid/droplets/current' do + let(:api_call) { ->(user_headers) { get "/v3/apps/#{droplet_model.app_guid}/droplets/current", nil, user_headers } } + let(:app_model) { VCAP::CloudController::AppModel.make(space_guid: space.guid) } + let(:package_model) { VCAP::CloudController::PackageModel.make(app_guid: app_model.guid) } + let!(:droplet_model) do + VCAP::CloudController::DropletModel.make( + app_guid: app_model.guid, + package_guid: package_model.guid, + buildpack_receipt_buildpack: 'http://buildpack.git.url.com', + error_description: 'example error', + execution_metadata: 'some-data', + droplet_hash: 'shalalala', + sha256_checksum: 'droplet-sha256-checksum', + process_types: { 'web' => 'start-command' } + ) + end + let(:expected_response) do + { + 'guid' => droplet_model.guid, + 'state' => VCAP::CloudController::DropletModel::STAGED_STATE, + 'error' => 'example error', + 'lifecycle' => { + 'type' => 'buildpack', + 'data' => {} + }, + 'checksum' => { 'type' => 'sha256', 'value' => 'droplet-sha256-checksum' }, + 'buildpacks' => [{ 'name' => 'http://buildpack.git.url.com', 'detect_output' => nil, 'buildpack_name' => nil, 'version' => nil }], + 'stack' => 'stack-name', + 'execution_metadata' => 'some-data', + 'process_types' => { 'web' => 'start-command' }, + 'image' => nil, + 'created_at' => iso8601, + 'updated_at' => iso8601, + 'relationships' => { 'app' => { 'data' => { 'guid' => app_model.guid } } }, + 'links' => { + 'self' => { 'href' => "#{link_prefix}/v3/droplets/#{droplet_model.guid}" }, + 'package' => { 'href' => "#{link_prefix}/v3/packages/#{package_model.guid}" }, + 'app' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}" }, + 'download' => { 'href' => "#{link_prefix}/v3/droplets/#{droplet_model.guid}/download" }, + 'assign_current_droplet' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/relationships/current_droplet", 'method' => 'PATCH' } + }, + 'metadata' => { + 'labels' => {}, + 'annotations' => {} + } + } + end + let(:expected_codes_and_responses) do + h = Hash.new({ code: 200, response_object: expected_response }.freeze) + h['no_role'] = { code: 404 } + h['org_billing_manager'] = { code: 404 } + h['org_auditor'] = { code: 404 } + h + end + + before do + droplet_model.buildpack_lifecycle_data.update(buildpacks: ['http://buildpack.git.url.com'], stack: 'stack-name') + app_model.droplet_guid = droplet_model.guid + app_model.save + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + + describe 'PATCH /v3/apps/:guid/relationships/current_droplet' do + let(:stack) { VCAP::CloudController::Stack.make(name: 'stack-name') } + let(:app_model) do + VCAP::CloudController::AppModel.make( + :buildpack, + name: 'my_app', + space: space, + desired_state: 'STOPPED' + ) + end + let(:droplet) do + VCAP::CloudController::DropletModel.make( + :docker, + app: app_model, + process_types: { web: 'rackup' }, + state: VCAP::CloudController::DropletModel::STAGED_STATE, + package: VCAP::CloudController::PackageModel.make + ) + end + let(:request_body) { { data: { guid: droplet.guid } } } + + before do + app_model.lifecycle_data.buildpacks = ['http://example.com/git'] + app_model.lifecycle_data.stack = stack.name + app_model.lifecycle_data.save + end + + context 'assigning the current droplet of the app' do + let(:api_call) { ->(user_headers) { patch "/v3/apps/#{app_model.guid}/relationships/current_droplet", request_body.to_json, user_headers } } + let(:current_droplet_response_object) do + { + 'data' => { + 'guid' => droplet.guid + }, + 'links' => { + 'self' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/relationships/current_droplet" }, + 'related' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/droplets/current" } + } + } + end + + let(:expected_codes_and_responses) do + h = Hash.new({ code: 403, errors: CF_NOT_AUTHORIZED }.freeze) + h['no_role'] = { code: 404 } + h['org_auditor'] = { code: 404 } + h['org_billing_manager'] = { code: 404 } + h['admin'] = { + code: 200, + response_object: current_droplet_response_object + } + h['space_supporter'] = { + code: 200, + response_object: current_droplet_response_object + } + h['space_developer'] = { + code: 200, + response_object: current_droplet_response_object + } + h + end + + before do + space.organization.add_user(user) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + + context 'when organization is suspended' do + let(:expected_codes_and_responses) do + h = super() + %w[space_supporter space_developer].each { |r| h[r] = { code: 403, errors: CF_ORG_SUSPENDED } } + h + end + + before do + org.update(status: VCAP::CloudController::Organization::SUSPENDED) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + end + + context 'events' do + before do + space.organization.add_user(user) + space.add_developer(user) + end + + it 'creates audit.app.droplet.mapped event' do + patch "/v3/apps/#{app_model.guid}/relationships/current_droplet", request_body.to_json, user_header + + events = VCAP::CloudController::Event.where(actor: user.guid).all + + droplet_event = events.find { |e| e.type == 'audit.app.droplet.mapped' } + expect(droplet_event.values).to include({ + type: 'audit.app.droplet.mapped', + actee: app_model.guid, + actee_type: 'app', + actee_name: 'my_app', + actor: user.guid, + actor_type: 'user', + actor_name: user_email, + actor_username: user_name, + space_guid: space.guid, + organization_guid: space.organization.guid + }) + expect(droplet_event.metadata).to eq({ 'request' => { 'droplet_guid' => droplet.guid } }) + + expect(app_model.reload.processes.count).to eq(1) + end + + context 'with two process types' do + let(:droplet) do + VCAP::CloudController::DropletModel.make( + app: app_model, + process_types: { web: 'rackup', other: 'cron' }, + state: VCAP::CloudController::DropletModel::STAGED_STATE + ) + end + + it 'creates audit.app.process.create events for each process' do + patch "/v3/apps/#{app_model.guid}/relationships/current_droplet", request_body.to_json, user_header + + expect(last_response.status).to eq(200) + + events = VCAP::CloudController::Event.where(actor: user.guid).all + + expect(app_model.reload.processes.count).to eq(2) + web_process = app_model.processes.find { |i| i.type == 'web' } + other_process = app_model.processes.find { |i| i.type == 'other' } + expect(web_process).to be_present + expect(other_process).to be_present + + web_process_event = events.find { |e| e.metadata['process_guid'] == web_process.guid } + expect(web_process_event.values).to include({ + type: 'audit.app.process.create', + actee: app_model.guid, + actee_type: 'app', + actee_name: 'my_app', + actor: user.guid, + actor_type: 'user', + actor_name: user_email, + actor_username: user_name, + space_guid: space.guid, + organization_guid: space.organization.guid + }) + expect(web_process_event.metadata).to eq({ 'process_guid' => web_process.guid, 'process_type' => 'web' }) + + other_process_event = events.find { |e| e.metadata['process_guid'] == other_process.guid } + expect(other_process_event.values).to include({ + type: 'audit.app.process.create', + actee: app_model.guid, + actee_type: 'app', + actee_name: 'my_app', + actor: user.guid, + actor_type: 'user', + actor_name: user_email, + actor_username: user_name, + space_guid: space.guid, + organization_guid: space.organization.guid + }) + expect(other_process_event.metadata).to eq({ 'process_guid' => other_process.guid, 'process_type' => 'other' }) + end + end + end + + context 'sidecars' do + let(:droplet) do + VCAP::CloudController::DropletModel.make( + :docker, + app: app_model, + process_types: { web: 'rackup' }, + state: VCAP::CloudController::DropletModel::STAGED_STATE, + package: VCAP::CloudController::PackageModel.make, + sidecars: + [ + { + name: 'sidecar_one', + command: 'bundle exec rackup', + process_types: ['web'], + memory: 300 + } + ] + ) + end + + before do + space.organization.add_user(user) + space.add_developer(user) + end + + it 'creates sidecars that were saved on the droplet' do + patch "/v3/apps/#{app_model.guid}/relationships/current_droplet", request_body.to_json, user_header + + expect(last_response.status).to eq(200) + + expect(app_model.reload.processes.count).to eq(1) + expect(app_model.reload.sidecars.count).to eq(1) + end + + it 'logs the create-sidecar event' do + Timecop.freeze do + expected_json = { + 'telemetry-source' => 'cloud_controller_ng', + 'telemetry-time' => Time.now.to_datetime.rfc3339, + 'create-sidecar' => { + 'api-version' => 'v3', + 'origin' => 'buildpack', + 'memory-in-mb' => 300, + 'process-types' => ['web'], + 'app-id' => OpenSSL::Digest::SHA256.hexdigest(app_model.guid) + } + } + expect_any_instance_of(ActiveSupport::Logger).to receive(:info).with(Oj.dump(expected_json)) + + patch "/v3/apps/#{app_model.guid}/relationships/current_droplet", request_body.to_json, user_header + + expect(last_response.status).to eq(200), last_response.body + end + end + end + end + + describe 'PATCH /v3/apps/:guid/environment_variables' do + before do + space.organization.add_user(user) + end + + let(:update_request) do + { + var: { + override: 'new-value', + new_key: 'brand-new-value' + } + } + end + let(:app_model) do + VCAP::CloudController::AppModel.make( + name: 'name1', + space: space, + desired_state: 'STOPPED', + environment_variables: { + override: 'original', + preserve: 'keep' + } + ) + end + let(:api_call) { ->(user_headers) { patch "/v3/apps/#{app_model.guid}/environment_variables", update_request.to_json, user_headers } } + let(:app_model_response_object) do + { + 'var' => { + 'override' => 'new-value', + 'new_key' => 'brand-new-value', + 'preserve' => 'keep' + }, + 'links' => { + 'self' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/environment_variables" }, + 'app' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}" } + } + } + end + let(:expected_codes_and_responses) do + h = Hash.new({ code: 404 }.freeze) + %w[global_auditor admin_read_only org_manager space_auditor space_manager].each do |r| + h[r] = { code: 403, errors: CF_NOT_AUTHORIZED } + end + h['admin'] = h['space_developer'] = h['space_supporter'] = { + code: 200, + response_object: app_model_response_object + } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + + context 'when organization is suspended' do + let(:expected_codes_and_responses) do + h = super() + %w[space_developer space_supporter].each { |r| h[r] = { code: 403, errors: CF_ORG_SUSPENDED } } + h + end + + before do + org.update(status: VCAP::CloudController::Organization::SUSPENDED) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + end + + describe 'GET /v3/apps/:guid/environment_variables' do + let(:app_model) { VCAP::CloudController::AppModel.make(name: 'my_app', space: space, desired_state: 'STARTED', environment_variables: { meep: 'moop' }) } + let(:api_call) { ->(user_headers) { get "/v3/apps/#{app_model.guid}/environment_variables", nil, user_headers } } + let(:app_model_response_object) do + { + var: { + meep: 'moop' + }, + links: { + self: { href: "#{link_prefix}/v3/apps/#{app_model.guid}/environment_variables" }, + app: { href: "#{link_prefix}/v3/apps/#{app_model.guid}" } + } + } + end + + before do + space.organization.add_user(user) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS do + let(:expected_codes_and_responses) do + h = Hash.new({ code: 404 }.freeze) + h['global_auditor'] = h['org_manager'] = h['space_auditor'] = h['space_manager'] = { code: 403 } + h['admin'] = h['admin_read_only'] = h['space_developer'] = h['space_supporter'] = { + code: 200, + response_object: app_model_response_object + } + h + 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_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS do + let(:expected_codes_and_responses) do + h = Hash.new({ code: 404 }.freeze) + h['global_auditor'] = h['org_manager'] = h['space_auditor'] = h['space_manager'] = h['space_developer'] = h['space_supporter'] = { code: 403 } + h['admin'] = h['admin_read_only'] = { + code: 200, + response_object: app_model_response_object + } + h + end + end + end + + context 'when the encryption_key_label is invalid' do + before do + allow_any_instance_of(ErrorPresenter).to receive(:raise_500?).and_return(false) + end + + it 'fails to decrypt the environment variables and returns a 500 error' do + app_model # ensure that app model is created before run_cipher is mocked to throw an error + allow(VCAP::CloudController::Encryptor).to receive(:run_cipher).and_raise(VCAP::CloudController::Encryptor::EncryptorError) + api_call.call(admin_headers) + + expect(last_response).to have_status_code(500) + expect(parsed_response['errors'].first['detail']).to match(/Error while processing encrypted data/i) + end + end + end + + describe 'GET /v3/apps/:guid/permissions' do + let(:org) { VCAP::CloudController::Organization.make } + let(:space) { VCAP::CloudController::Space.make(organization: org) } + let(:app_model) { VCAP::CloudController::AppModel.make(name: 'name1', space: space, desired_state: 'STOPPED') } + let(:api_call) { ->(user_headers) { get "/v3/apps/#{app_model.guid}/permissions", nil, user_headers } } + + let(:read_all_response) do + { + read_basic_data: true, + read_sensitive_data: true + } + end + + let(:read_basic_response) do + { + read_basic_data: true, + read_sensitive_data: false + } + end + + let(:expected_codes_and_responses) do + h = Hash.new({ code: 404 }.freeze) + h['admin'] = { code: 200, response_object: read_all_response } + h['admin_read_only'] = { code: 200, response_object: read_all_response } + h['global_auditor'] = { code: 200, response_object: read_basic_response } + h['org_manager'] = { code: 200, response_object: read_basic_response } + h['space_manager'] = { code: 200, response_object: read_basic_response } + h['space_auditor'] = { code: 200, response_object: read_basic_response } + h['space_developer'] = { code: 200, response_object: read_all_response } + h['space_supporter'] = { code: 200, response_object: read_basic_response } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end +end diff --git a/spec/request/routes/apps_routes_spec.rb b/spec/request/routes/apps_routes_spec.rb deleted file mode 100644 index 8357d590370..00000000000 --- a/spec/request/routes/apps_routes_spec.rb +++ /dev/null @@ -1,173 +0,0 @@ -require 'spec_helper' -require 'request_spec_shared_examples' -require_relative 'shared_context' - -# Split from spec/request/routes_spec.rb for better test parallelization - -RSpec.describe 'Routes Request' do - include_context 'routes request spec' - - describe 'GET /v3/apps/:app_guid/routes' do - let(:app_model) { VCAP::CloudController::AppModel.make(space:) } - let(:route1) { VCAP::CloudController::Route.make(space:) } - let(:route2) { VCAP::CloudController::Route.make(space:) } - let!(:route3) { VCAP::CloudController::Route.make(space:) } - let!(:route_mapping1) { VCAP::CloudController::RouteMappingModel.make(app: app_model, route: route1, process_type: 'web') } - let!(:route_mapping2) { VCAP::CloudController::RouteMappingModel.make(app: app_model, route: route2, process_type: 'admin') } - let(:api_call) { ->(user_headers) { get "/v3/apps/#{app_model.guid}/routes", nil, user_headers } } - - let(:route1_json) do - { - guid: route1.guid, - protocol: route1.domain.protocols[0], - host: route1.host, - path: route1.path, - port: nil, - url: "#{route1.host}.#{route1.domain.name}#{route1.path}", - created_at: iso8601, - updated_at: iso8601, - destinations: contain_exactly({ - guid: route_mapping1.guid, - app: { - guid: app_model.guid, - process: { - type: route_mapping1.process_type - } - }, - weight: route_mapping1.weight, - port: route_mapping1.presented_port, - protocol: 'http1', - created_at: iso8601, - updated_at: iso8601 - }), - relationships: { - space: { - data: { guid: route1.space.guid } - }, - domain: { - data: { guid: route1.domain.guid } - } - }, - metadata: { - labels: {}, - annotations: {} - }, - links: { - self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{route1.guid}} }, - space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{route1.space.guid}} }, - destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{route1.guid}/destinations} }, - domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{route1.domain.guid}} } - }, - options: {} - } - end - - let(:route2_json) do - { - guid: route2.guid, - protocol: route2.domain.protocols[0], - host: route2.host, - path: route2.path, - port: nil, - url: "#{route2.host}.#{route2.domain.name}#{route2.path}", - created_at: iso8601, - updated_at: iso8601, - destinations: contain_exactly({ - guid: route_mapping2.guid, - app: { - guid: app_model.guid, - process: { - type: route_mapping2.process_type - } - }, - weight: route_mapping2.weight, - port: route_mapping2.presented_port, - protocol: 'http1', - created_at: iso8601, - updated_at: iso8601 - }), - relationships: { - space: { - data: { guid: route2.space.guid } - }, - domain: { - data: { guid: route2.domain.guid } - } - }, - metadata: { - labels: {}, - annotations: {} - }, - links: { - self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{route2.guid}} }, - space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{route2.space.guid}} }, - destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{route2.guid}/destinations} }, - domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{route2.domain.guid}} } - }, - options: {} - } - end - - context 'when the user is a member in the app space' do - let(:expected_codes_and_responses) do - h = Hash.new( - { code: 200, - response_objects: [route1_json, route2_json] }.freeze - ) - - h['org_auditor'] = { code: 404 } - h['org_billing_manager'] = { code: 404 } - h['no_role'] = { code: 404 } - h - end - - it_behaves_like 'permissions for list endpoint', ALL_PERMISSIONS - end - - context 'ports filter' do - # Don't even think of converting the following hash to symbols ('type' => 'tcp' NOT type: 'tcp'), and you need to set the GUID - let(:router_group) { VCAP::CloudController::RoutingApi::RouterGroup.new({ 'type' => 'tcp', 'reservable_ports' => '7777,8888,9999', 'guid' => 'some-guid' }) } - let(:routing_api_client) { instance_double(VCAP::CloudController::RoutingApi::Client) } - - before do - allow_any_instance_of(CloudController::DependencyLocator).to receive(:routing_api_client).and_return(routing_api_client) - allow(routing_api_client).to receive_messages(enabled?: true, router_group: router_group) - end - - context 'when there are multiple TCP routes with different ports' do - # The following `let`s depend on the above `before do` - let(:domain_tcp) { VCAP::CloudController::SharedDomain.make(router_group_guid: router_group.guid, name: 'my.domain') } - let!(:route_with_ports_0) do - VCAP::CloudController::Route.make(host: '', space: space, domain: domain_tcp, guid: 'route-with-port-0', port: 7777) - end - let!(:route_with_ports_1) do - VCAP::CloudController::Route.make(host: '', space: space, domain: domain_tcp, guid: 'route-with-port-1', port: 8888) - end - let!(:route_with_ports_2) do - VCAP::CloudController::Route.make(host: '', space: space, domain: domain_tcp, guid: 'route-with-port-2', port: 9999) - end - let!(:route_mapping_1) { VCAP::CloudController::RouteMappingModel.make(app: app_model, route: route_with_ports_1, process_type: 'web') } - let!(:route_mapping_2) { VCAP::CloudController::RouteMappingModel.make(app: app_model, route: route_with_ports_2, process_type: 'web') } - - it 'returns routes filtered by ports' do - get "/v3/apps/#{app_model.guid}/routes?ports=7777,8888", nil, admin_header - expect(last_response).to have_status_code(200) - expect(parsed_response['resources'].size).to eq(1) - expect(parsed_response['resources'].first['port']).to eq(route_with_ports_1.port) - end - end - end - - describe 'eager loading' do - it 'eager loads associated resources that the presenter specifies' do - expect(VCAP::CloudController::RouteFetcher).to receive(:fetch).with( - anything, - hash_including(eager_loaded_associations: %i[domain space route_mappings labels annotations]) - ).and_call_original - - get "/v3/apps/#{app_model.guid}/routes", nil, admin_header - expect(last_response).to have_status_code(200) - end - end - end -end diff --git a/spec/request/routes/create_spec.rb b/spec/request/routes/create_spec.rb deleted file mode 100644 index 4d6d709d4c6..00000000000 --- a/spec/request/routes/create_spec.rb +++ /dev/null @@ -1,1290 +0,0 @@ -require 'spec_helper' -require 'request_spec_shared_examples' -require_relative 'shared_context' - -# Split from spec/request/routes_spec.rb for better test parallelization - -RSpec.describe 'Routes Request' do - include_context 'routes request spec' - - describe 'POST /v3/routes' do - context 'when creating a route in a tcp domain' do - let(:domain) { VCAP::CloudController::SharedDomain.make(router_group_guid: 'some-router-group', router_group_type: 'tcp') } - - before do - token = { token_type: 'Bearer', access_token: 'my-favourite-access-token' } - stub_request(:post, 'https://uaa.service.cf.internal/oauth/token'). - to_return(status: 200, body: token.to_json, headers: { 'Content-Type' => 'application/json' }) - stub_request(:get, 'http://localhost:3000/routing/v1/router_groups'). - to_return(status: 200, body: '[{"guid":"some-router-group","name":"Robby Router","type":"tcp","reservable_ports":"25555"}]', headers: {}) - end - - context 'and the route has a host' do - let(:params) do - { - host: 'my-host', - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - it 'returns a 422 and a helpful error message' do - post '/v3/routes', params.to_json, admin_header - expect(last_response).to have_status_code(422) - expect(last_response).to have_error_message('Hosts are not supported for TCP routes.') - end - end - - context 'and the route has a path' do - let(:params) do - { - path: '/cgi-bin', - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - it 'returns a 422 and a helpful error message' do - post '/v3/routes', params.to_json, admin_header - expect(last_response).to have_status_code(422) - expect(last_response).to have_error_message('Paths are not supported for TCP routes.') - end - end - end - - context 'when creating a route in a scoped domain' do - let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } - - describe 'when creating a route without a host' do - let(:params) do - { - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - let(:route_json) do - { - guid: UUID_REGEX, - protocol: domain.protocols[0], - host: '', - path: '', - port: nil, - url: domain.name, - created_at: iso8601, - updated_at: iso8601, - destinations: [], - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - }, - links: { - self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, - space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}} }, - destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, - domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } - }, - metadata: { - labels: {}, - annotations: {} - }, - options: {} - } - end - - describe 'valid routes' do - let(:api_call) { ->(user_headers) { post '/v3/routes', params.to_json, user_headers } } - - let(:expected_codes_and_responses) do - h = Hash.new( - { code: 403 }.freeze - ) - h['admin'] = { - code: 201, - response_object: route_json - } - h['space_developer'] = { - code: 201, - response_object: route_json - } - h['space_supporter'] = { - code: 201, - response_object: route_json - } - h - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - end - - describe 'when creating a route with a host' do - let(:params) do - { - host: 'some-host', - path: '/some-path', - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - }, - metadata: { - labels: { potato: 'yam' }, - annotations: { style: 'mashed' } - }, - options: {} - } - end - - let(:route_json) do - { - guid: UUID_REGEX, - protocol: domain.protocols[0], - host: 'some-host', - path: '/some-path', - port: nil, - url: "some-host.#{domain.name}/some-path", - created_at: iso8601, - updated_at: iso8601, - destinations: [], - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - }, - links: { - self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, - space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}} }, - destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, - domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } - }, - metadata: { - labels: { potato: 'yam' }, - annotations: { style: 'mashed' } - }, - options: {} - } - end - - describe 'valid routes' do - it_behaves_like 'permissions for single object endpoint', ['admin'] do - let(:api_call) { ->(user_headers) { post '/v3/routes', params.to_json, user_headers } } - - let(:expected_codes_and_responses) do - h = Hash.new( - { code: 403 }.freeze - ) - h['admin'] = { - code: 201, - response_object: route_json - } - h['space_developer'] = { - code: 201, - response_object: route_json - } - h['space_supporter'] = { - code: 201, - response_object: route_json - } - h - end - - let(:expected_event_hash) do - { - type: 'audit.route.create', - actee: parsed_response['guid'], - actee_type: 'route', - actee_name: 'some-host', - metadata: { request: params }.to_json, - space_guid: space.guid, - organization_guid: org.guid - } - end - end - end - end - - describe 'when creating a route with a wildcard host' do - let(:params) do - { - host: '*', - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - let(:route_json) do - { - guid: UUID_REGEX, - protocol: domain.protocols[0], - host: '*', - path: '', - port: nil, - url: "*.#{domain.name}", - created_at: iso8601, - updated_at: iso8601, - destinations: [], - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - }, - links: { - self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, - space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}} }, - destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, - domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } - }, - metadata: { - labels: {}, - annotations: {} - }, - options: {} - } - end - - describe 'valid routes' do - let(:api_call) { ->(user_headers) { post '/v3/routes', params.to_json, user_headers } } - - let(:expected_codes_and_responses) do - h = Hash.new( - { code: 403 }.freeze - ) - h['admin'] = { - code: 201, - response_object: route_json - } - h['space_developer'] = { - code: 201, - response_object: route_json - } - h['space_supporter'] = { - code: 201, - response_object: route_json - } - h - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - end - end - - context 'when creating a route in an unscoped domain' do - let(:domain) { VCAP::CloudController::SharedDomain.make } - - describe 'when creating a route without a host' do - let(:params) do - { - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - it 'fails with a helpful message' do - post '/v3/routes', params.to_json, admin_header - expect(last_response).to have_status_code(422) - expect(last_response).to have_error_message('Missing host. Routes in shared domains must have a host defined.') - end - end - - describe 'when creating a route with a host' do - let(:params) do - { - host: 'some-host', - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - let(:route_json) do - { - guid: UUID_REGEX, - protocol: domain.protocols[0], - host: 'some-host', - path: '', - port: nil, - url: "some-host.#{domain.name}", - created_at: iso8601, - updated_at: iso8601, - destinations: [], - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - }, - links: { - self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, - space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}} }, - destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, - domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } - }, - metadata: { - labels: {}, - annotations: {} - }, - options: {} - } - end - - describe 'valid routes' do - let(:api_call) { ->(user_headers) { post '/v3/routes', params.to_json, user_headers } } - - let(:expected_codes_and_responses) do - h = Hash.new( - { code: 403 }.freeze - ) - h['admin'] = { - code: 201, - response_object: route_json - } - h['space_developer'] = { - code: 201, - response_object: route_json - } - h['space_supporter'] = { - code: 201, - response_object: route_json - } - h - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - end - - describe 'when creating a route with a wildcard host' do - let(:params) do - { - host: '*', - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - let(:route_json) do - { - guid: UUID_REGEX, - protocol: domain.protocols[0], - host: '*', - path: '', - port: nil, - url: "*.#{domain.name}", - created_at: iso8601, - updated_at: iso8601, - destinations: [], - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - }, - links: { - self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, - space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}} }, - destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, - domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } - }, - metadata: { - labels: {}, - annotations: {} - }, - options: {} - } - end - - describe 'valid routes' do - let(:api_call) { ->(user_headers) { post '/v3/routes', params.to_json, user_headers } } - - let(:expected_codes_and_responses) do - h = Hash.new( - { code: 403 }.freeze - ) - h['admin'] = { - code: 201, - response_object: route_json - } - h['space_developer'] = { - code: 422 - } - h['space_supporter'] = { - code: 422 - } - h - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - end - - describe 'the domain supports tcp routes' do - let(:router_group) { VCAP::CloudController::RoutingApi::RouterGroup.new({ 'type' => 'tcp', 'reservable_ports' => '123' }) } - let(:domain) { VCAP::CloudController::SharedDomain.make(router_group_guid: 'some-router-group', name: 'my.domain') } - let(:routing_api_client) { instance_double(VCAP::CloudController::RoutingApi::Client) } - - before do - TestConfig.override( - kubernetes: { host_url: nil }, - external_domain: 'api2.vcap.me', - external_protocol: 'https' - ) - allow_any_instance_of(CloudController::DependencyLocator).to receive(:routing_api_client).and_return(routing_api_client) - allow(routing_api_client).to receive_messages(enabled?: true, router_group: router_group) - end - - let(:params) do - { - port: 123, - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - let(:api_call) { ->(user_headers) { post '/v3/routes', params.to_json, user_headers } } - - let(:route_json) do - { - guid: UUID_REGEX, - port: 123, - host: '', - path: '', - protocol: 'tcp', - url: "#{domain.name}:123", - created_at: iso8601, - updated_at: iso8601, - destinations: [], - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - }, - links: { - self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, - space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}} }, - destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, - domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } - }, - metadata: { - labels: {}, - annotations: {} - }, - options: {} - } - end - - let(:expected_codes_and_responses) do - h = Hash.new( - { code: 403 }.freeze - ) - h['admin'] = { - code: 201, - response_object: route_json - } - h['space_developer'] = { - code: 201, - response_object: route_json - } - h['space_supporter'] = { - code: 201, - response_object: route_json - } - h - end - - context 'and the user provides a valid port' do - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - - context 'and a route with the domain and port already exist' do - let!(:duplicate_route) { VCAP::CloudController::Route.make(host: '', space: space, domain: domain, port: 123) } - - it 'fails with a helpful error message' do - post '/v3/routes', params.to_json, admin_headers - expect(last_response).to have_status_code(422) - expect(last_response).to have_error_message("Route already exists with port '123' for domain 'my.domain'.") - end - end - - context 'and the port is already in use for the router group' do - let!(:other_domain) { VCAP::CloudController::SharedDomain.make(router_group_guid: 'some-router-group', name: 'my.domain2') } - let!(:route_with_port) { VCAP::CloudController::Route.make(host: '', space: space, domain: other_domain, port: 123) } - - it 'fails with a helpful error message' do - post '/v3/routes', params.to_json, admin_headers - expect(last_response).to have_status_code(422) - expect(last_response).to have_error_message("Port '123' is not available. Try a different port or use a different domain.") - end - end - end - - context 'and the user does not provide a port' do - let(:params) do - { - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - - context 'and randomly selected port is already in use' do - let(:existing_route) { VCAP::CloudController::Route.make(host: '', space: space, domain: domain, port: 123) } - - let(:params) do - { - port: existing_route.port, - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - it 'fails with a helpful error message' do - post '/v3/routes', params.to_json, admin_headers - expect(last_response).to have_status_code(422) - expect(last_response).to have_error_message("Route already exists with port '123' for domain 'my.domain'.") - end - end - end - end - end - - context 'when creating a route in a suspended org' do - before do - org.update(status: VCAP::CloudController::Organization::SUSPENDED) - end - - let(:domain) { VCAP::CloudController::SharedDomain.make } - - let(:params) do - { - host: 'some-host', - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - let(:route_json) do - { - guid: UUID_REGEX, - protocol: domain.protocols[0], - host: 'some-host', - path: '', - port: nil, - url: "some-host.#{domain.name}", - created_at: iso8601, - updated_at: iso8601, - destinations: [], - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - }, - metadata: { - labels: {}, - annotations: {} - }, - links: { - self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, - space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}} }, - destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, - domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } - }, - options: {} - } - end - - describe 'valid routes' do - let(:api_call) { ->(user_headers) { post '/v3/routes', params.to_json, user_headers } } - - let(:expected_codes_and_responses) do - h = Hash.new({ code: 403, errors: CF_NOT_AUTHORIZED }.freeze) - h['admin'] = { - code: 201, - response_object: route_json - } - %w[space_developer space_supporter].each { |r| h[r] = { code: 403, errors: CF_ORG_SUSPENDED } } - h - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - end - - context 'when creating a route in an internal domain' do - let(:domain) { VCAP::CloudController::SharedDomain.make(internal: true) } - - describe 'when creating a route with a wildcard host' do - let(:params) do - { - host: '*', - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - it 'fails with a helpful message' do - post '/v3/routes', params.to_json, admin_header - expect(last_response).to have_status_code(422) - expect(last_response).to have_error_message('Wildcard hosts are not supported for internal domains.') - end - end - - describe 'when creating a route with a path' do - let(:params) do - { - host: 'host', - path: '/apath', - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - it 'fails with a helpful message' do - post '/v3/routes', params.to_json, admin_header - expect(last_response).to have_status_code(422) - expect(last_response).to have_error_message('Paths are not supported for internal domains.') - end - end - - describe 'when creating a route with a host' do - let(:params) do - { - host: 'some-host', - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - let(:route_json) do - { - guid: UUID_REGEX, - protocol: domain.protocols[0], - host: 'some-host', - path: '', - port: nil, - url: "some-host.#{domain.name}", - created_at: iso8601, - updated_at: iso8601, - destinations: [], - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - }, - links: { - self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, - space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}} }, - destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, - domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } - }, - metadata: { - labels: {}, - annotations: {} - }, - options: {} - } - end - - describe 'valid routes' do - let(:api_call) { ->(user_headers) { post '/v3/routes', params.to_json, user_headers } } - - let(:expected_codes_and_responses) do - h = Hash.new( - { code: 403 }.freeze - ) - h['admin'] = { - code: 201, - response_object: route_json - } - h['space_developer'] = { - code: 201, - response_object: route_json - } - h['space_supporter'] = { - code: 201, - response_object: route_json - } - h - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - end - end - - context 'when the domain has an owning org that is different from the space\'s parent org' do - let(:other_org) { VCAP::CloudController::Organization.make } - let(:inaccessible_domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: other_org) } - - let(:params_with_inaccessible_domain) do - { - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: inaccessible_domain.guid } - } - } - } - end - - it 'returns a 422 with a helpful error message' do - post '/v3/routes', params_with_inaccessible_domain.to_json, admin_header - expect(last_response).to have_status_code(422) - expect(last_response).to have_error_message("Invalid domain. Domain '#{inaccessible_domain.name}' is not available in organization '#{org.name}'.") - end - end - - context 'when the host-less route has already been created for this domain' do - let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } - let!(:existing_route) { VCAP::CloudController::Route.make(host: '', space: space, domain: domain) } - - let(:params_for_duplicate_route) do - { - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - it 'returns a 422 with a helpful error message' do - post '/v3/routes', params_for_duplicate_route.to_json, admin_header - expect(last_response).to have_status_code(422) - expect(last_response).to have_error_message("Route already exists for domain '#{domain.name}'.") - end - end - - context 'when there is already a route' do - context 'with the host/domain/path combination' do - let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } - let!(:existing_route) { VCAP::CloudController::Route.make(host: 'my-host', path: '/existing', space: space, domain: domain) } - - let(:params_for_duplicate_route) do - { - host: existing_route.host, - path: existing_route.path, - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - it 'returns a 422 with a helpful error message' do - post '/v3/routes', params_for_duplicate_route.to_json, admin_header - expect(last_response).to have_status_code(422) - expect(last_response).to have_error_message("Route already exists with host '#{existing_route.host}' and path '#{existing_route.path}' for domain '#{domain.name}'.") - end - end - - context 'with the host/domain combination' do - let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } - let!(:existing_route) { VCAP::CloudController::Route.make(host: 'my-host', space: space, domain: domain) } - - let(:params_for_duplicate_route) do - { - host: existing_route.host, - path: existing_route.path, - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - it 'returns a 422 with a helpful error message' do - post '/v3/routes', params_for_duplicate_route.to_json, admin_header - expect(last_response).to have_status_code(422) - expect(last_response).to have_error_message("Route already exists with host '#{existing_route.host}' for domain '#{domain.name}'.") - end - end - end - - context 'when there is already a domain matching the host/domain combination' do - let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } - let!(:existing_domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization, name: "#{params[:host]}.#{domain.name}") } - - let(:params) do - { - host: 'some-host', - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - it 'returns a 422 with a helpful error message' do - post '/v3/routes', params.to_json, admin_header - expect(last_response).to have_status_code(422) - expect(last_response).to have_error_message("Route conflicts with domain '#{existing_domain.name}'.") - end - end - - context 'when using a reserved system hostname with the system domain' do - let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } - - let(:params) do - { - host: 'host', - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - before do - VCAP::CloudController::Config.config.set(:system_domain, domain.name) - VCAP::CloudController::Config.config.set(:system_hostnames, [params[:host]]) - end - - it 'returns a 422 with a helpful error message' do - post '/v3/routes', params.to_json, admin_header - expect(last_response).to have_status_code(422) - expect(last_response).to have_error_message('Route conflicts with a reserved system route.') - end - end - - context 'when using a non-reserved hostname with the system domain' do - let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } - let(:api_call) { ->(user_headers) { post '/v3/routes', params.to_json, user_headers } } - - let(:params) do - { - host: 'host', - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - let(:route_json) do - { - guid: UUID_REGEX, - protocol: domain.protocols[0], - host: params[:host], - path: '', - port: nil, - url: "#{params[:host]}.#{domain.name}", - created_at: iso8601, - updated_at: iso8601, - destinations: [], - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - }, - metadata: { - labels: {}, - annotations: {} - }, - links: { - self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, - space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}} }, - destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, - domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } - }, - options: {} - } - end - - let(:expected_codes_and_responses) do - h = Hash.new( - { code: 403 }.freeze - ) - h['admin'] = { - code: 201, - response_object: route_json - } - h['space_developer'] = { - code: 201, - response_object: route_json - } - h['space_supporter'] = { - code: 201, - response_object: route_json - } - h - end - - before do - VCAP::CloudController::Config.config.set(:system_domain, domain.name) - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - - describe 'quotas' do - context 'when the space quota for routes is maxed out' do - let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } - let!(:space_quota_definition) { VCAP::CloudController::SpaceQuotaDefinition.make(total_routes: 0, organization: org) } - let!(:space_with_quota) { VCAP::CloudController::Space.make(space_quota_definition: space_quota_definition, organization: org) } - - let(:params_for_space_with_quota) do - { - relationships: { - space: { - data: { guid: space_with_quota.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - it 'returns a 422 with a helpful error message' do - post '/v3/routes', params_for_space_with_quota.to_json, admin_header - expect(last_response).to have_status_code(422) - expect(last_response).to have_error_message("Routes quota exceeded for space '#{space_with_quota.name}'.") - end - end - - context 'when the org quota for routes is maxed out' do - let!(:org_quota_definition) { VCAP::CloudController::QuotaDefinition.make(total_routes: 0, total_reserved_route_ports: 0) } - let!(:org_with_quota) { VCAP::CloudController::Organization.make(quota_definition: org_quota_definition) } - let!(:space_in_org_with_quota) do - VCAP::CloudController::Space.make(organization: org_with_quota) - end - let(:domain_in_org_with_quota) { VCAP::CloudController::Domain.make(owning_organization: org_with_quota) } - - let(:params_for_org_with_quota) do - { - relationships: { - space: { - data: { guid: space_in_org_with_quota.guid } - }, - domain: { - data: { guid: domain_in_org_with_quota.guid } - } - } - } - end - - it 'returns a 422 with a helpful error message' do - post '/v3/routes', params_for_org_with_quota.to_json, admin_header - expect(last_response).to have_status_code(422) - expect(last_response).to have_error_message("Routes quota exceeded for organization '#{org_with_quota.name}'.") - end - end - end - - context 'when the feature flag is disabled' do - let(:headers) { set_user_with_header_as_role(user: user, role: 'space_developer', org: org, space: space) } - let!(:feature_flag) { VCAP::CloudController::FeatureFlag.make(name: 'route_creation', enabled: false, error_message: 'my name is bob') } - let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } - let(:params) do - { - host: 'some-host', - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - context 'when the user is not an admin' do - it 'returns a 403' do - post '/v3/routes', params.to_json, headers - - expect(last_response).to have_status_code(403) - expect(parsed_response['errors'][0]['detail']).to eq('Feature Disabled: my name is bob') - end - end - - context 'when the user is an admin' do - let(:headers) { set_user_with_header_as_role(role: 'admin') } - - it 'allows creation' do - post '/v3/routes', params.to_json, headers - - expect(last_response).to have_status_code(201) - end - end - end - - context 'when the user is not logged in' do - it 'returns 401 for Unauthenticated requests' do - post '/v3/routes', {}.to_json, base_json_headers - expect(last_response).to have_status_code(401) - end - end - - context 'when the user does not have the required scopes' do - let(:user_header) { headers_for(user, scopes: ['cloud_controller.read']) } - - it 'returns a 403' do - post '/v3/routes', {}.to_json, user_header - expect(last_response).to have_status_code(403) - end - end - - context 'when the space does not exist' do - let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } - - let(:params_with_invalid_space) do - { - relationships: { - space: { - data: { guid: 'invalid-space' } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - it 'returns a 422 with a helpful error message' do - post '/v3/routes', params_with_invalid_space.to_json, admin_header - expect(last_response).to have_status_code(422) - expect(last_response).to have_error_message('Invalid space. Ensure that the space exists and you have access to it.') - end - end - - context 'when the domain does not exist' do - let(:params_with_invalid_domain) do - { - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: 'invalid-domain' } - } - } - } - end - - it 'returns a 422 with a helpful error message' do - post '/v3/routes', params_with_invalid_domain.to_json, admin_header - expect(last_response).to have_status_code(422) - expect(last_response).to have_error_message('Invalid domain. Ensure that the domain exists and you have access to it.') - end - end - - context 'when communicating with the routing API' do - let(:routing_api_client) { instance_double(VCAP::CloudController::RoutingApi::Client) } - let(:router_group) { VCAP::CloudController::RoutingApi::RouterGroup.new({ 'type' => 'tcp', 'guid' => 'some-guid' }) } - let(:headers) { set_user_with_header_as_role(role: 'admin') } - let(:domain_tcp) { VCAP::CloudController::SharedDomain.make(router_group_guid: router_group.guid, name: 'my.domain') } - let(:params) do - { - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain_tcp.guid } - } - } - } - end - - before do - allow_any_instance_of(CloudController::DependencyLocator).to receive(:routing_api_client).and_return(routing_api_client) - end - - context 'when UAA is unavailable' do - before do - allow(routing_api_client).to receive(:router_group).and_raise VCAP::CloudController::RoutingApi::UaaUnavailable - end - - it 'returns a 503 with a helpful error message' do - post '/v3/routes', params.to_json, headers - - expect(last_response).to have_status_code(503) - expect(parsed_response['errors'][0]['detail']).to eq 'Communicating with the Routing API failed because UAA is currently unavailable. Please try again later.' - end - end - - context 'when the routing API is unavailable' do - before do - allow(routing_api_client).to receive(:enabled?).and_return true - allow(routing_api_client).to receive(:router_group).and_raise VCAP::CloudController::RoutingApi::RoutingApiUnavailable - end - - it 'returns a 503 with a helpful error message' do - post '/v3/routes', params.to_json, headers - - expect(last_response).to have_status_code(503) - expect(parsed_response['errors'][0]['detail']).to eq 'The Routing API is currently unavailable. Please try again later.' - end - end - - context 'when the routing API is disabled' do - before do - allow(routing_api_client).to receive(:enabled?).and_return false - allow(routing_api_client).to receive(:router_group).and_raise VCAP::CloudController::RoutingApi::RoutingApiDisabled - end - - it 'returns a 503 with a helpful error message' do - post '/v3/routes', params.to_json, headers - - expect(last_response).to have_status_code(503) - expect(parsed_response['errors'][0]['detail']).to eq 'The Routing API is disabled.' - end - end - - context 'when the router group is unavailable' do - let(:domain_tcp) { VCAP::CloudController::SharedDomain.make(router_group_guid: 'not a guid', name: 'my.domain') } - - before do - allow(routing_api_client).to receive_messages(enabled?: true, router_group: nil) - end - - it 'returns a 503 with a helpful error message' do - post '/v3/routes', params.to_json, headers - - expect(last_response.status).to eq(422) - expect(parsed_response['errors'][0]['detail']).to eq 'Route could not be created because the specified domain does not have a valid router group.' - end - end - end - end -end diff --git a/spec/request/routes/list_spec.rb b/spec/request/routes/list_spec.rb deleted file mode 100644 index 4a987141e3a..00000000000 --- a/spec/request/routes/list_spec.rb +++ /dev/null @@ -1,938 +0,0 @@ -require 'spec_helper' -require 'request_spec_shared_examples' -require_relative 'shared_context' - -# Split from spec/request/routes_spec.rb for better test parallelization - -RSpec.describe 'Routes Request' do - include_context 'routes request spec' - - describe 'GET /v3/routes' do - let(:other_space) { VCAP::CloudController::Space.make(name: 'b-space') } - let(:app_model) { VCAP::CloudController::AppModel.make(space:) } - let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } - let!(:route_in_org) do - VCAP::CloudController::Route.make(space: space, domain: domain, host: 'host-1', path: '/path1', guid: 'route-in-org-guid') - end - let!(:route_in_other_org) do - VCAP::CloudController::Route.make(space: other_space, host: 'host-2', path: '/path2', guid: 'route-in-other-org-guid') - end - let!(:route_in_org_dest_web) { VCAP::CloudController::RouteMappingModel.make(app: app_model, route: route_in_org, process_type: 'web') } - let!(:route_in_org_dest_worker) { VCAP::CloudController::RouteMappingModel.make(app: app_model, route: route_in_org, process_type: 'worker') } - let(:api_call) { ->(user_headers) { get '/v3/routes', nil, user_headers } } - let(:route_in_org_json) do - { - guid: route_in_org.guid, - protocol: route_in_org.domain.protocols[0], - host: route_in_org.host, - path: route_in_org.path, - port: nil, - url: "#{route_in_org.host}.#{route_in_org.domain.name}#{route_in_org.path}", - created_at: iso8601, - updated_at: iso8601, - destinations: contain_exactly({ - guid: route_in_org_dest_web.guid, - app: { - guid: app_model.guid, - process: { - type: route_in_org_dest_web.process_type - } - }, - weight: route_in_org_dest_web.weight, - port: route_in_org_dest_web.presented_port, - protocol: 'http1', - created_at: iso8601, - updated_at: iso8601 - }, { - guid: route_in_org_dest_worker.guid, - app: { - guid: app_model.guid, - process: { - type: route_in_org_dest_worker.process_type - } - }, - weight: route_in_org_dest_worker.weight, - port: route_in_org_dest_worker.presented_port, - protocol: 'http1', - created_at: iso8601, - updated_at: iso8601 - }), - relationships: { - space: { - data: { guid: route_in_org.space.guid } - }, - domain: { - data: { guid: route_in_org.domain.guid } - } - }, - metadata: { - labels: {}, - annotations: {} - }, - options: {}, - links: { - self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{route_in_org.guid}} }, - space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{route_in_org.space.guid}} }, - destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{route_in_org.guid}/destinations} }, - domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{route_in_org.domain.guid}} } - } - } - end - - let(:route_in_other_org_json) do - { - guid: route_in_other_org.guid, - protocol: route_in_other_org.domain.protocols[0], - host: route_in_other_org.host, - path: route_in_other_org.path, - port: nil, - url: "#{route_in_other_org.host}.#{route_in_other_org.domain.name}#{route_in_other_org.path}", - created_at: iso8601, - updated_at: iso8601, - destinations: [], - relationships: { - space: { - data: { guid: route_in_other_org.space.guid } - }, - domain: { - data: { guid: route_in_other_org.domain.guid } - } - }, - metadata: { - labels: {}, - annotations: {} - }, - options: {}, - links: { - self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{route_in_other_org.guid}} }, - space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{route_in_other_org.space.guid}} }, - destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{route_in_other_org.guid}/destinations} }, - domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{route_in_other_org.domain.guid}} } - } - } - end - - it_behaves_like 'list_endpoint_with_common_filters' do - let(:resource_klass) { VCAP::CloudController::Route } - let(:api_call) do - ->(headers, filters) { get "/v3/routes?#{filters}", nil, headers } - end - let(:headers) { admin_headers } - end - - describe 'query list parameters' do - it_behaves_like 'list query endpoint' do - let(:request) { 'v3/routes' } - let(:message) { VCAP::CloudController::RoutesListMessage } - let(:user_header) { admin_header } - - let(:params) do - { - page: '2', - per_page: '10', - order_by: 'updated_at', - space_guids: %w[foo bar], - service_instance_guids: %w[baz qux], - organization_guids: %w[foo bar], - domain_guids: %w[foo bar], - app_guids: %w[foo bar], - guids: %w[foo bar], - paths: %w[foo bar], - hosts: 'foo', - ports: 636, - include: 'domain', - label_selector: 'foo,bar', - created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", - updated_ats: { gt: Time.now.utc.iso8601 } - } - end - end - end - - context 'when the user is a member in the routes org' do - let(:expected_codes_and_responses) do - h = Hash.new( - { code: 200, - response_objects: [route_in_org_json] }.freeze - ) - - h['admin'] = { code: 200, response_objects: [route_in_org_json, route_in_other_org_json] } - h['admin_read_only'] = { code: 200, response_objects: [route_in_org_json, route_in_other_org_json] } - h['global_auditor'] = { code: 200, response_objects: [route_in_org_json, route_in_other_org_json] } - - h['org_billing_manager'] = { code: 200, response_objects: [] } - h['no_role'] = { code: 200, response_objects: [] } - h - end - - it_behaves_like 'permissions for list endpoint', ALL_PERMISSIONS - end - - describe 'includes' do - context 'when including domains' do - let(:domain1) { VCAP::CloudController::SharedDomain.make(name: 'first-domain.example.com') } - let(:domain1_json) do - { - guid: domain1.guid, - created_at: iso8601, - updated_at: iso8601, - name: domain1.name, - internal: false, - router_group: nil, - supported_protocols: ['http'], - metadata: { - labels: {}, - annotations: {} - }, - relationships: { - organization: { - data: nil - }, - shared_organizations: { - data: [] - } - }, - links: { - self: { href: "#{link_prefix}/v3/domains/#{domain1.guid}" }, - route_reservations: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain1.guid}/route_reservations} } - } - } - end - - let(:domain2) { VCAP::CloudController::SharedDomain.make(name: 'second-domain.example.com') } - let(:domain2_json) do - { - guid: domain2.guid, - created_at: iso8601, - updated_at: iso8601, - name: domain2.name, - internal: false, - router_group: nil, - supported_protocols: ['http'], - metadata: { - labels: {}, - annotations: {} - }, - relationships: { - organization: { - data: nil - }, - shared_organizations: { - data: [] - } - }, - links: { - self: { href: "#{link_prefix}/v3/domains/#{domain1.guid}" }, - route_reservations: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain1.guid}/route_reservations} } - } - } - end - - let(:domain2) { VCAP::CloudController::SharedDomain.make(name: 'second-domain.example.com') } - let(:domain2_json) do - { - guid: domain2.guid, - created_at: iso8601, - updated_at: iso8601, - name: domain2.name, - internal: false, - router_group: nil, - supported_protocols: ['http'], - metadata: { - labels: {}, - annotations: {} - }, - relationships: { - organization: { - data: nil - }, - shared_organizations: { - data: [] - } - }, - links: { - self: { href: "#{link_prefix}/v3/domains/#{domain2.guid}" }, - route_reservations: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain2.guid}/route_reservations} } - } - } - end - - let!(:route1_domain1) do - VCAP::CloudController::Route.make(space: space, host: 'route1', domain: domain1, path: '/path1', guid: 'route1-guid') - end - let(:route1_domain1_json) do - { - guid: route1_domain1.guid, - protocol: route1_domain1.domain.protocols[0], - created_at: iso8601, - updated_at: iso8601, - host: route1_domain1.host, - path: route1_domain1.path, - port: nil, - url: "#{route1_domain1.host}.#{domain1.name}#{route1_domain1.path}", - destinations: [], - metadata: { - labels: {}, - annotations: {} - }, - relationships: { - space: { - data: { - guid: space.guid - } - }, - domain: { - data: { - guid: domain1.guid - } - } - }, - options: {}, - links: { - self: { href: "http://api2.vcap.me/v3/routes/#{route1_domain1.guid}" }, - space: { href: "http://api2.vcap.me/v3/spaces/#{space.guid}" }, - destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{route1_domain1.guid}/destinations} }, - domain: { href: "http://api2.vcap.me/v3/domains/#{domain1.guid}" } - } - } - end - - let!(:route_in_org) do - VCAP::CloudController::Route.make(space: space, domain: domain1, host: 'host-1', path: '/path1', guid: 'route-in-org-guid') - end - let!(:route_in_other_org) do - VCAP::CloudController::Route.make(space: other_space, domain: domain2, host: 'host-2', path: '/path2', guid: 'route-in-other-org-guid') - end - - it 'includes the unique domains for the routes' do - get '/v3/routes?include=domain', nil, admin_header - expect(last_response).to have_status_code(200) - expect({ - resources: parsed_response['resources'], - included: parsed_response['included'] - }).to match_json_response({ - resources: [route_in_org_json, route_in_other_org_json, route1_domain1_json], - included: { 'domains' => [domain1_json, domain2_json] } - }) - end - end - - context 'when including spaces and orgs' do - it 'includes the unique spaces and organizations for the routes' do - get '/v3/routes?include=space,space.organization', nil, admin_header - expect(last_response).to have_status_code(200) - expect({ - resources: parsed_response['resources'], - included: parsed_response['included'] - }).to match_json_response({ - resources: [route_in_org_json, route_in_other_org_json], - included: { - 'spaces' => [ - space_json_generator.call(space), - space_json_generator.call(other_space) - ], - 'organizations' => [ - org_json_generator.call(org), - org_json_generator.call(other_space.organization) - ] - } - }) - end - end - - context 'when including spaces' do - it 'eagerly loads spaces to efficiently access space_guid' do - expect(VCAP::CloudController::IncludeSpaceDecorator).to receive(:decorate) do |_, resources| - expect(resources).not_to be_empty - resources.each { |r| expect(r.associations).to include(:space) } - end - - get '/v3/routes?include=space', nil, admin_header - expect(last_response).to have_status_code(200) - end - end - - context 'when including orgs' do - it 'eagerly loads spaces to efficiently access space.organization_id' do - expect(VCAP::CloudController::IncludeOrganizationDecorator).to receive(:decorate) do |_, resources| - expect(resources).not_to be_empty - resources.each { |r| expect(r.associations).to include(:space) } - end - - get '/v3/routes?include=space.organization', nil, admin_header - expect(last_response).to have_status_code(200) - end - end - end - - describe 'filters' do - let!(:route_without_host_and_with_path) do - VCAP::CloudController::Route.make(space: space, host: '', domain: domain, path: '/path1', guid: 'route-without-host') - end - let!(:route_without_host_and_with_path2) do - VCAP::CloudController::Route.make(space: space, host: '', domain: domain, path: '/path2', guid: 'route-without-host2') - end - let(:route_without_host_and_with_path_json) do - { - guid: 'route-without-host', - protocol: domain.protocols[0], - created_at: iso8601, - updated_at: iso8601, - destinations: [], - host: '', - path: '/path1', - port: nil, - url: "#{domain.name}/path1", - metadata: { - labels: {}, - annotations: {} - }, - relationships: { - space: { - data: { - guid: space.guid - } - }, - domain: { - data: { - guid: domain.guid - } - } - }, - options: {}, - links: { - self: { href: 'http://api2.vcap.me/v3/routes/route-without-host' }, - space: { href: "http://api2.vcap.me/v3/spaces/#{space.guid}" }, - destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/route-without-host/destinations} }, - domain: { href: "http://api2.vcap.me/v3/domains/#{domain.guid}" } - } - } - end - let(:route_without_host_and_with_path2_json) do - { - guid: 'route-without-host2', - protocol: domain.protocols[0], - created_at: iso8601, - updated_at: iso8601, - destinations: [], - host: '', - path: '/path2', - port: nil, - url: "#{domain.name}/path2", - metadata: { - labels: {}, - annotations: {} - }, - relationships: { - space: { - data: { - guid: space.guid - } - }, - domain: { - data: { - guid: domain.guid - } - } - }, - options: {}, - links: { - self: { href: 'http://api2.vcap.me/v3/routes/route-without-host2' }, - space: { href: "http://api2.vcap.me/v3/spaces/#{space.guid}" }, - destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/route-without-host2/destinations} }, - domain: { href: "http://api2.vcap.me/v3/domains/#{domain.guid}" } - } - } - end - let!(:route_without_path_and_with_host) do - VCAP::CloudController::Route.make(space: space, host: 'host-1', domain: domain, path: '', guid: 'route-without-path') - end - let(:route_without_path_and_with_host_json) do - { - guid: 'route-without-path', - protocol: domain.protocols[0], - created_at: iso8601, - updated_at: iso8601, - destinations: [], - host: 'host-1', - path: '', - port: nil, - url: "host-1.#{domain.name}", - metadata: { - labels: {}, - annotations: {} - }, - relationships: { - space: { - data: { - guid: space.guid - } - }, - domain: { - data: { - guid: domain.guid - } - } - }, - options: {}, - links: { - self: { href: 'http://api2.vcap.me/v3/routes/route-without-path' }, - space: { href: "http://api2.vcap.me/v3/spaces/#{space.guid}" }, - destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/route-without-path/destinations} }, - domain: { href: "http://api2.vcap.me/v3/domains/#{domain.guid}" } - } - } - end - - context 'hosts filter' do - it 'returns routes filtered by host' do - get '/v3/routes?hosts=host-1', nil, admin_header - expect(last_response).to have_status_code(200) - expect({ - resources: parsed_response['resources'] - }).to match_json_response({ - resources: [route_in_org_json, route_without_path_and_with_host_json] - }) - end - - it 'returns route with no host if one exists when filtering by empty host' do - get '/v3/routes?hosts=', nil, admin_header - expect(last_response).to have_status_code(200) - expect({ - resources: parsed_response['resources'] - }).to match_json_response({ - resources: [route_without_host_and_with_path_json, route_without_host_and_with_path2_json] - }) - end - end - - context 'paths filter' do - it 'returns routes filtered by path' do - get '/v3/routes?paths=%2Fpath1', nil, admin_header - expect(last_response).to have_status_code(200) - expect({ - resources: parsed_response['resources'] - }).to match_json_response({ - resources: [route_in_org_json, route_without_host_and_with_path_json] - }) - end - - it 'returns route with no path when filtering by empty path' do - get '/v3/routes?paths=', nil, admin_header - expect(last_response).to have_status_code(200) - expect({ - resources: parsed_response['resources'] - }).to match_json_response({ - resources: [route_without_path_and_with_host_json] - }) - end - end - - context 'hosts and paths filter' do - it 'returns routes with no host and the provided path when host is empty' do - get '/v3/routes?paths=%2Fpath1&hosts=', nil, admin_header - expect(last_response).to have_status_code(200) - expect({ - resources: parsed_response['resources'] - }).to match_json_response({ - resources: [route_without_host_and_with_path_json] - }) - end - end - - context 'organization_guids filter' do - it 'returns routes filtered by organization_guid' do - get "/v3/routes?organization_guids=#{other_space.organization.guid}", nil, admin_header - expect(last_response).to have_status_code(200) - expect({ - resources: parsed_response['resources'] - }).to match_json_response({ - resources: [route_in_other_org_json] - }) - end - end - - context 'space_guids filter' do - it 'returns routes filtered by space_guid' do - get "/v3/routes?space_guids=#{other_space.guid}", nil, admin_header - expect(last_response).to have_status_code(200) - expect({ - resources: parsed_response['resources'] - }).to match_json_response({ - resources: [route_in_other_org_json] - }) - end - end - - context 'domain_guids filter' do - it 'returns routes filtered by domain_guid' do - get "/v3/routes?domain_guids=#{route_in_other_org.domain.guid}", nil, admin_header - expect(last_response).to have_status_code(200) - expect({ - resources: parsed_response['resources'] - }).to match_json_response({ - resources: [route_in_other_org_json] - }) - end - end - - context 'app_guids filter' do - it 'returns routes filtered by app_guid' do - get "/v3/routes?app_guids=#{app_model.guid}", nil, admin_header - expect(last_response).to have_status_code(200) - expect(parsed_response['resources'].size).to eq(1) - expect(parsed_response['resources'].first['destinations'].size).to eq(2) - expect( - parsed_response['resources'].first['destinations'].map { |destination| destination['app']['guid'] }.uniq - ).to eq([app_model.guid]) - end - end - - context 'ports filter' do - # Don't even think of converting the following hash to symbols ('type' => 'tcp' NOT type: 'tcp'), and you need to set the GUID - let(:router_group) { VCAP::CloudController::RoutingApi::RouterGroup.new({ 'type' => 'tcp', 'reservable_ports' => '7777,8888,9999', 'guid' => 'some-guid' }) } - let(:routing_api_client) { instance_double(VCAP::CloudController::RoutingApi::Client) } - - before do - allow_any_instance_of(CloudController::DependencyLocator).to receive(:routing_api_client).and_return(routing_api_client) - allow(routing_api_client).to receive_messages(enabled?: true, router_group: router_group) - end - - context 'when there are multiple TCP routes with different ports' do - # The following `let`s depend on the above `before do` - let(:domain_tcp) { VCAP::CloudController::SharedDomain.make(router_group_guid: router_group.guid, name: 'my.domain') } - let!(:route_with_ports_0) do - VCAP::CloudController::Route.make(host: '', space: space, domain: domain_tcp, guid: 'route-with-port-0', port: 7777) - end - let!(:route_with_ports_1) do - VCAP::CloudController::Route.make(host: '', space: space, domain: domain_tcp, guid: 'route-with-port-1', port: 8888) - end - let!(:route_with_ports_2) do - VCAP::CloudController::Route.make(host: '', space: space, domain: domain_tcp, guid: 'route-with-port-2', port: 9999) - end - - it 'returns routes filtered by ports' do - get '/v3/routes?ports=7777,8888', nil, admin_header - expect(last_response).to have_status_code(200) - expect(parsed_response['resources'].size).to eq(2) - expect(parsed_response['resources'].pluck('port')).to contain_exactly(route_with_ports_0.port, route_with_ports_1.port) - end - end - end - - context 'service instance guids filter' do - let(:service_instance_one) do - VCAP::CloudController::ManagedServiceInstance.make(:routing, space: space, name: 'si-name-1') - end - let(:service_instance_two) do - VCAP::CloudController::ManagedServiceInstance.make(:routing, space: space, name: 'si-name-2') - end - - let!(:route_with_service_instance_one) do - VCAP::CloudController::Route.make(space: space, host: 'host-with-service-instance-one', domain: domain, path: '/path1', guid: 'route-with-service-instance-one') - end - let!(:route_with_service_instance_two) do - VCAP::CloudController::Route.make(space: space, host: 'host-with-service-instance-two', domain: domain, path: '/path2', guid: 'route-with-service-instance-two') - end - - let!(:route_mapping_one) { VCAP::CloudController::RouteBinding.make(route: route_with_service_instance_one, service_instance: service_instance_one) } - let!(:route_mapping_two) { VCAP::CloudController::RouteBinding.make(route: route_with_service_instance_two, service_instance: service_instance_two) } - - it 'returns routes filtered by service instance guid' do - get "/v3/routes?service_instance_guids=#{service_instance_one.guid},#{service_instance_two.guid}", nil, admin_header - expect(last_response).to have_status_code(200) - expect(parsed_response['resources'].size).to eq(2) - expect(parsed_response['resources'].pluck('guid')).to contain_exactly('route-with-service-instance-one', 'route-with-service-instance-two') - end - end - end - - describe 'labels' do - let!(:domain1) { VCAP::CloudController::PrivateDomain.make(name: 'dom1.com', owning_organization: org) } - let!(:route1) { VCAP::CloudController::Route.make(space: space, domain: domain1, host: 'hall', path: '/oates', guid: 'guid-1') } - let!(:route1_label) { VCAP::CloudController::RouteLabelModel.make(resource_guid: route1.guid, key_name: 'animal', value: 'dog') } - - let!(:domain2) { VCAP::CloudController::PrivateDomain.make(name: 'dom2.com', owning_organization: org) } - let!(:route2) { VCAP::CloudController::Route.make(space: space, domain: domain2, guid: 'guid-2') } - let!(:route2_label) { VCAP::CloudController::RouteLabelModel.make(resource_guid: route2.guid, key_name: 'animal', value: 'cow') } - let!(:route2__exclusive_label) { VCAP::CloudController::RouteLabelModel.make(resource_guid: route2.guid, key_name: 'santa', value: 'claus') } - - describe 'label_selectors' do - it 'returns a 200 and the filtered routes for "in" label selector' do - get '/v3/routes?label_selector=animal in (dog)', nil, admin_header - - expect(last_response).to have_status_code(200), last_response.body - parsed_response = Oj.load(last_response.body) - - expected_pagination = { - 'total_results' => 1, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal+in+%28dog%29&page=1&per_page=50" }, - 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal+in+%28dog%29&page=1&per_page=50" }, - 'next' => nil, - 'previous' => nil - } - - expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route1.guid) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - - it 'returns a 200 and the filtered routes for "in" label selector with space guids' do - get "/v3/routes?label_selector=animal in (dog)&space_guids=#{space.guid}", nil, admin_header - - expect(last_response).to have_status_code(200) - parsed_response = Oj.load(last_response.body) - - expected_pagination = { - 'total_results' => 1, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal+in+%28dog%29&page=1&per_page=50&space_guids=#{space.guid}" }, - 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal+in+%28dog%29&page=1&per_page=50&space_guids=#{space.guid}" }, - 'next' => nil, - 'previous' => nil - } - - expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route1.guid) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - - it 'returns a 200 and the filtered routes for "in" label selector with org filters' do - get "/v3/routes?label_selector=animal in (dog)&organization_guids=#{org.guid}", nil, admin_header - - expect(last_response).to have_status_code(200), last_response.body - parsed_response = Oj.load(last_response.body) - - expected_pagination = { - 'total_results' => 1, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal+in+%28dog%29&organization_guids=#{org.guid}&page=1&per_page=50" }, - 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal+in+%28dog%29&organization_guids=#{org.guid}&page=1&per_page=50" }, - 'next' => nil, - 'previous' => nil - } - - expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route1.guid) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - - it 'returns a 200 and the filtered routes for "in" label selector with domain filters' do - get "/v3/routes?label_selector=animal in (dog)&domain_guids=#{domain1.guid}", nil, admin_header - - expect(last_response).to have_status_code(200), last_response.body - parsed_response = Oj.load(last_response.body) - - expected_pagination = { - 'total_results' => 1, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/routes?domain_guids=#{domain1.guid}&label_selector=animal+in+%28dog%29&page=1&per_page=50" }, - 'last' => { 'href' => "#{link_prefix}/v3/routes?domain_guids=#{domain1.guid}&label_selector=animal+in+%28dog%29&page=1&per_page=50" }, - 'next' => nil, - 'previous' => nil - } - - expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route1.guid) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - - it 'returns a 200 and the filtered routes for "in" label selector with host filters' do - get '/v3/routes?label_selector=animal in (dog)&hosts=hall', nil, admin_header - - expect(last_response).to have_status_code(200), last_response.body - parsed_response = Oj.load(last_response.body) - - expected_pagination = { - 'total_results' => 1, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/routes?hosts=hall&label_selector=animal+in+%28dog%29&page=1&per_page=50" }, - 'last' => { 'href' => "#{link_prefix}/v3/routes?hosts=hall&label_selector=animal+in+%28dog%29&page=1&per_page=50" }, - 'next' => nil, - 'previous' => nil - } - - expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route1.guid) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - - it 'returns a 200 and the filtered routes for "in" label selector with path filters' do - get '/v3/routes?label_selector=animal in (dog)&paths=/oates', nil, admin_header - - expect(last_response).to have_status_code(200) - parsed_response = Oj.load(last_response.body) - - expected_pagination = { - 'total_results' => 1, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal+in+%28dog%29&page=1&paths=%2Foates&per_page=50" }, - 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal+in+%28dog%29&page=1&paths=%2Foates&per_page=50" }, - 'next' => nil, - 'previous' => nil - } - - expect(last_response).to have_status_code(200) - expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route1.guid) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - end - - it 'returns a 200 and the filtered routes for "notin" label selector' do - get '/v3/routes?label_selector=animal notin (dog)', nil, admin_header - - parsed_response = Oj.load(last_response.body) - - expected_pagination = { - 'total_results' => 3, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal+notin+%28dog%29&page=1&per_page=50" }, - 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal+notin+%28dog%29&page=1&per_page=50" }, - 'next' => nil, - 'previous' => nil - } - - expect(last_response).to have_status_code(200) - expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route2.guid, route_in_org.guid, route_in_other_org.guid) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - - it 'returns a 200 and the filtered routes for "=" label selector' do - get '/v3/routes?label_selector=animal=dog', nil, admin_header - - parsed_response = Oj.load(last_response.body) - - expected_pagination = { - 'total_results' => 1, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal%3Ddog&page=1&per_page=50" }, - 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal%3Ddog&page=1&per_page=50" }, - 'next' => nil, - 'previous' => nil - } - - expect(last_response).to have_status_code(200) - expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route1.guid) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - - it 'returns a 200 and the filtered domains for "==" label selector' do - get '/v3/routes?label_selector=animal==dog', nil, admin_header - - parsed_response = Oj.load(last_response.body) - - expected_pagination = { - 'total_results' => 1, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal%3D%3Ddog&page=1&per_page=50" }, - 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal%3D%3Ddog&page=1&per_page=50" }, - 'next' => nil, - 'previous' => nil - } - - expect(last_response).to have_status_code(200) - expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route1.guid) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - - it 'returns a 200 and the filtered routes for "!=" label selector' do - get '/v3/routes?label_selector=animal!=dog', nil, admin_header - - parsed_response = Oj.load(last_response.body) - - expected_pagination = { - 'total_results' => 3, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal%21%3Ddog&page=1&per_page=50" }, - 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal%21%3Ddog&page=1&per_page=50" }, - 'next' => nil, - 'previous' => nil - } - - expect(last_response).to have_status_code(200) - expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route2.guid, route_in_org.guid, route_in_other_org.guid) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - - it 'returns a 200 and the filtered routes for "=" label selector' do - get '/v3/routes?label_selector=animal=cow,santa=claus', nil, admin_header - - parsed_response = Oj.load(last_response.body) - - expected_pagination = { - 'total_results' => 1, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal%3Dcow%2Csanta%3Dclaus&page=1&per_page=50" }, - 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal%3Dcow%2Csanta%3Dclaus&page=1&per_page=50" }, - 'next' => nil, - 'previous' => nil - } - - expect(last_response).to have_status_code(200) - expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route2.guid) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - - it 'returns a 200 and the filtered routes for existence label selector' do - get '/v3/routes?label_selector=santa', nil, admin_header - - parsed_response = Oj.load(last_response.body) - - expected_pagination = { - 'total_results' => 1, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=santa&page=1&per_page=50" }, - 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=santa&page=1&per_page=50" }, - 'next' => nil, - 'previous' => nil - } - - expect(last_response).to have_status_code(200) - expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route2.guid) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - - it 'returns a 200 and the filtered routes for non-existence label selector' do - get '/v3/routes?label_selector=!santa', nil, admin_header - - parsed_response = Oj.load(last_response.body) - - expected_pagination = { - 'total_results' => 3, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=%21santa&page=1&per_page=50" }, - 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=%21santa&page=1&per_page=50" }, - 'next' => nil, - 'previous' => nil - } - - expect(last_response).to have_status_code(200) - expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route1.guid, route_in_org.guid, route_in_other_org.guid) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - end - - describe 'eager loading' do - it 'eager loads associated resources that the presenter specifies' do - expect(VCAP::CloudController::RouteFetcher).to receive(:fetch).with( - anything, - hash_including(eager_loaded_associations: %i[domain space route_mappings labels annotations]) - ).and_call_original - - get '/v3/routes', nil, admin_header - expect(last_response).to have_status_code(200) - end - end - - context 'when the request is invalid' do - it 'returns 400 with a meaningful error' do - get '/v3/routes?page=potato', nil, admin_header - expect(last_response).to have_status_code(400) - expect(last_response).to have_error_message('The query parameter is invalid: Page must be a positive integer') - end - end - - context 'when the user is not logged in' do - it 'returns 401 for Unauthenticated requests' do - get '/v3/routes', nil, base_json_headers - expect(last_response).to have_status_code(401) - end - end - end -end diff --git a/spec/request/routes/shared_context.rb b/spec/request/routes/shared_context.rb deleted file mode 100644 index c634e429f6e..00000000000 --- a/spec/request/routes/shared_context.rb +++ /dev/null @@ -1,31 +0,0 @@ -require 'presenters/v3/space_presenter' -require 'presenters/v3/organization_presenter' - -RSpec.shared_context 'routes request spec' do - let(:user) { VCAP::CloudController::User.make } - let(:admin_header) { admin_headers_for(user) } - let!(:org) { VCAP::CloudController::Organization.make(created_at: 1.hour.ago) } - let!(:space) { VCAP::CloudController::Space.make(name: 'a-space', created_at: 1.hour.ago, organization: org) } - - let(:space_json_generator) do - lambda { |s| - presented_space = VCAP::CloudController::Presenters::V3::SpacePresenter.new(s).to_hash - presented_space[:created_at] = iso8601 - presented_space[:updated_at] = iso8601 - presented_space - } - end - - let(:org_json_generator) do - lambda { |o| - presented_space = VCAP::CloudController::Presenters::V3::OrganizationPresenter.new(o).to_hash - presented_space[:created_at] = iso8601 - presented_space[:updated_at] = iso8601 - presented_space - } - end - - before do - TestConfig.override(kubernetes: {}) - end -end diff --git a/spec/request/routes/sharing_spec.rb b/spec/request/routes/sharing_spec.rb deleted file mode 100644 index 00015c496d5..00000000000 --- a/spec/request/routes/sharing_spec.rb +++ /dev/null @@ -1,918 +0,0 @@ -require 'spec_helper' -require 'request_spec_shared_examples' -require_relative 'shared_context' - -# Split from spec/request/routes_spec.rb for better test parallelization - -RSpec.describe 'Routes Request' do - include_context 'routes request spec' - - describe 'GET /v3/routes/:guid/relationships/shared_spaces' do - let(:api_call) { ->(user_headers) { get "/v3/routes/#{guid}/relationships/shared_spaces", nil, user_headers } } - let(:target_space_1) { VCAP::CloudController::Space.make(organization: org) } - let(:route) do - route = VCAP::CloudController::Route.make(space:) - route.add_shared_space(target_space_1) - route - end - let(:guid) { route.guid } - let(:space_dev_headers) do - org.add_user(user) - space.add_developer(user) - headers_for(user) - end - let!(:feature_flag) do - VCAP::CloudController::FeatureFlag.make(name: 'route_sharing', enabled: true, error_message: nil) - end - - before do - org.add_user(user) - target_space_1.add_developer(user) - end - - describe 'permissions' do - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS do - let(:expected_codes_and_responses) do - h = Hash.new({ code: 200, response_object: { - data: [ - { - guid: target_space_1.guid - } - ], - links: { - self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{route.guid}/relationships/shared_spaces} } - } - } }.freeze) - - h['org_billing_manager'] = { code: 404 } - h['no_role'] = { code: 404 } - h - end - end - end - - describe 'when route_sharing flag is disabled' do - before do - feature_flag.enabled = false - feature_flag.save - end - - it 'makes users unable to unshare routes' do - api_call.call(space_dev_headers) - - expect(last_response).to have_status_code(403) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => 'Feature Disabled: route_sharing', - 'title' => 'CF-FeatureDisabled', - 'code' => 330_002 - } - ) - ) - end - end - - it 'responds with 404 when the route does not exist' do - get '/v3/routes/some-fake-guid/relationships/shared_spaces', nil, space_dev_headers - - expect(last_response).to have_status_code(404) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => 'Route not found', - 'title' => 'CF-ResourceNotFound' - } - ) - ) - end - end - - describe 'POST /v3/routes/:guid/relationships/shared_spaces' do - let(:api_call) { ->(user_headers) { post "/v3/routes/#{guid}/relationships/shared_spaces", request_body.to_json, user_headers } } - let(:target_space_1) { VCAP::CloudController::Space.make(organization: org) } - let(:target_space_2) { VCAP::CloudController::Space.make(organization: org) } - let(:request_body) do - { - 'data' => [ - { 'guid' => target_space_1.guid }, - { 'guid' => target_space_2.guid } - ] - } - end - let(:route) { VCAP::CloudController::Route.make(space:) } - let(:guid) { route.guid } - let(:space_dev_headers) do - org.add_user(user) - space.add_developer(user) - headers_for(user) - end - let!(:feature_flag) do - VCAP::CloudController::FeatureFlag.make(name: 'route_sharing', enabled: true, error_message: nil) - end - - before do - org.add_user(user) - target_space_1.add_developer(user) - target_space_2.add_developer(user) - end - - describe 'permissions' do - let(:expected_codes_and_responses) do - h = Hash.new({ code: 403, errors: CF_NOT_AUTHORIZED }.freeze) - - h['org_billing_manager'] = { code: 404 } - h['no_role'] = { code: 404 } - - h['admin'] = { code: 200 } - h['space_developer'] = { code: 200 } - h['space_supporter'] = { code: 200 } - h - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - - context 'when organization is suspended' do - let(:expected_codes_and_responses) do - h = super() - %w[space_developer space_supporter].each { |r| h[r] = { code: 403, errors: CF_ORG_SUSPENDED } } - h - end - - before do - org.update(status: VCAP::CloudController::Organization::SUSPENDED) - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - - context 'when target organization is suspended' do - let(:target_space_1) do - space = VCAP::CloudController::Space.make - space.organization.add_user(user) - space.organization.update(status: VCAP::CloudController::Organization::SUSPENDED) - space - end - - let(:expected_codes_and_responses) do - h = super() - %w[space_developer space_supporter].each { |r| h[r] = { code: 422 } } - h - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - end - - it 'shares the route to the target space and logs audit event' do - api_call.call(space_dev_headers) - - expect(last_response.status).to eq(200) - - event = VCAP::CloudController::Event.last - expect(event.values).to include({ - type: 'audit.route.share', - actor: user.guid, - actee_type: 'route', - actee_name: route.host, - space_guid: space.guid, - organization_guid: space.organization.guid - }) - expect(event.metadata['target_space_guids']).to include(target_space_1.guid, target_space_2.guid) - - route.reload - expect(route.shared_spaces).to include(target_space_1, target_space_2) - end - - it 'reports that the route is now shared' do - api_call.call(space_dev_headers) - - expect(last_response.status).to eq(200) - route.reload - expect(route.shared_spaces).to include(target_space_1, target_space_2) - expect(route).to be_shared - end - - it 'reports that the route is not shared when it has not been shared' do - route.reload - expect(route.shared_spaces).to be_empty - expect(route).not_to be_shared - end - - describe 'when route_sharing flag is disabled' do - before do - feature_flag.enabled = false - feature_flag.save - end - - it 'makes users unable to share routes' do - api_call.call(space_dev_headers) - - expect(last_response).to have_status_code(403) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => 'Feature Disabled: route_sharing', - 'title' => 'CF-FeatureDisabled', - 'code' => 330_002 - } - ) - ) - end - end - - it 'responds with 404 when the route does not exist' do - post '/v3/routes/some-fake-guid/relationships/shared_spaces', request_body.to_json, space_dev_headers - - expect(last_response).to have_status_code(404) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => 'Route not found', - 'title' => 'CF-ResourceNotFound' - } - ) - ) - end - - describe 'when the request body is invalid' do - context 'when it is not a valid relationship' do - let(:request_body) do - { - 'data' => { 'guid' => target_space_1.guid } - } - end - - it 'responds with 422' do - api_call.call(space_dev_headers) - - expect(last_response.status).to eq(422) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => 'Data must be an array', - 'title' => 'CF-UnprocessableEntity' - } - ) - ) - end - end - - context 'when there are additional keys' do - let(:request_body) do - { - 'data' => [ - { 'guid' => target_space_1.guid } - ], - 'fake-key' => 'foo' - } - end - - it 'responds with 422' do - api_call.call(space_dev_headers) - - expect(last_response.status).to eq(422) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => "Unknown field(s): 'fake-key'", - 'title' => 'CF-UnprocessableEntity' - } - ) - ) - end - end - end - - describe 'target space to share to' do - context 'does not exist' do - let(:target_space_guid) { 'fake-target' } - let(:request_body) do - { - 'data' => [ - { 'guid' => target_space_guid } - ] - } - end - - it 'responds with 422' do - api_call.call(space_dev_headers) - - expect(last_response.status).to eq(422) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => "Unable to share route #{route.uri} with spaces ['#{target_space_guid}']. " \ - 'Ensure the spaces exist and that you have access to them.', - 'title' => 'CF-UnprocessableEntity' - } - ) - ) - end - end - - context 'user does not have access to one of the target spaces' do - let(:no_access_target_space) { VCAP::CloudController::Space.make(organization: org) } - let(:request_body) do - { - 'data' => [ - { 'guid' => no_access_target_space.guid }, - { 'guid' => target_space_1.guid } - ] - } - end - - it 'responds with 422 and does not share the route' do - api_call.call(space_dev_headers) - - expect(last_response.status).to eq(422) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => "Unable to share route #{route.uri} with spaces ['#{no_access_target_space.guid}']. " \ - 'Ensure the spaces exist and that you have access to them.', - 'title' => 'CF-UnprocessableEntity' - } - ) - ) - - route.reload - expect(route).not_to be_shared - end - end - - context 'already owns the route' do - let(:request_body) do - { - 'data' => [ - { 'guid' => space.guid }, - { 'guid' => target_space_1.guid } - ] - } - end - - it 'responds with 422 and does not share the route' do - api_call.call(space_dev_headers) - - expect(last_response.status).to eq(422) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => "Unable to share route '#{route.uri}' with space '#{space.guid}'. " \ - 'Routes cannot be shared into the space where they were created.', - 'title' => 'CF-UnprocessableEntity' - } - ) - ) - - route.reload - expect(route).not_to be_shared - end - end - end - - describe 'errors while sharing' do - # isolation segments? - end - end - - describe 'DELETE /v3/routes/:guid/relationships/shared_spaces/:space_guid' do - let(:api_call) { ->(user_headers) { delete "/v3/routes/#{guid}/relationships/shared_spaces/#{unshared_space_guid}", request_body.to_json, user_headers } } - let(:target_space_1) { VCAP::CloudController::Space.make(organization: org) } - let(:target_space_2) { VCAP::CloudController::Space.make(organization: org) } - let(:target_space_3) { VCAP::CloudController::Space.make(organization: org) } - let(:target_space_not_shared_with_route) { VCAP::CloudController::Space.make(organization: org) } - let(:space_to_unshare) { target_space_2 } - let(:unshared_space_guid) { space_to_unshare.guid } - let(:request_body) { {} } - let(:route) do - route = VCAP::CloudController::Route.make(space:) - route.add_shared_space(target_space_1) - route.add_shared_space(target_space_2) - route.add_shared_space(target_space_3) - route - end - let(:guid) { route.guid } - let(:space_dev_headers) do - org.add_user(user) - space.add_developer(user) - headers_for(user) - end - let!(:feature_flag) do - VCAP::CloudController::FeatureFlag.make(name: 'route_sharing', enabled: true, error_message: nil) - end - - before do - org.add_user(user) - target_space_1.add_developer(user) - target_space_2.add_developer(user) - target_space_not_shared_with_route.add_developer(user) - end - - describe 'permissions' do - let(:expected_codes_and_responses) do - h = Hash.new({ code: 403, errors: CF_NOT_AUTHORIZED }.freeze) - - h['org_billing_manager'] = { code: 404 } - h['no_role'] = { code: 404 } - - h['admin'] = { code: 204 } - h['space_developer'] = { code: 204 } - h['space_supporter'] = { code: 204 } - h - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - - context 'when organization is suspended' do - let(:expected_codes_and_responses) do - h = super() - %w[space_developer space_supporter].each { |r| h[r] = { code: 403, errors: CF_ORG_SUSPENDED } } - h - end - - before do - org.update(status: VCAP::CloudController::Organization::SUSPENDED) - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - - context 'when target organization is suspended' do - let(:space_to_unshare) do - space = VCAP::CloudController::Space.make - space.organization.add_user(user) - space.add_developer(user) - space.organization.update(status: VCAP::CloudController::Organization::SUSPENDED) - space - end - - let(:expected_codes_and_responses) do - h = super() - %w[space_developer space_supporter].each do |r| - h[r] = { - code: 422, - errors: [{ - detail: "Unable to unshare route '#{route.uri}' from space '#{space_to_unshare.guid}'. The target organization is suspended.", - title: 'CF-UnprocessableEntity', - code: 10_008 - }] - } - end - h - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - end - - it 'unshares the specified route from the target space and logs audit event' do - expect(route.shared_spaces).to include(target_space_1, space_to_unshare, target_space_3) - - api_call.call(space_dev_headers) - - expect(last_response.status).to eq(204) - - event = VCAP::CloudController::Event.last - expect(event.values).to include({ - type: 'audit.route.unshare', - actor: user.guid, - actee_type: 'route', - actee_name: route.host, - space_guid: space.guid, - organization_guid: space.organization.guid - }) - expect(event.metadata['target_space_guid']).to eq(unshared_space_guid) - - route.reload - expect(route.shared_spaces).to include(target_space_1, target_space_3) - end - - describe 'when route_sharing flag is disabled' do - before do - feature_flag.enabled = false - feature_flag.save - end - - it 'makes users unable to unshare routes' do - api_call.call(space_dev_headers) - - expect(last_response).to have_status_code(403) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => 'Feature Disabled: route_sharing', - 'title' => 'CF-FeatureDisabled', - 'code' => 330_002 - } - ) - ) - end - end - - it 'responds with 204 when the route is not shared with the specified space' do - delete "/v3/routes/#{route.guid}/relationships/shared_spaces/#{target_space_not_shared_with_route.guid}", request_body.to_json, space_dev_headers - - expect(last_response.status).to eq(204) - end - - it "responds with 404 when the route doesn't exist" do - delete "/v3/routes/some-fake-guid/relationships/shared_spaces/#{target_space_1.guid}", request_body.to_json, space_dev_headers - - expect(last_response).to have_status_code(404) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => 'Route not found', - 'title' => 'CF-ResourceNotFound' - } - ) - ) - end - - context 'attempting to unshare from space that owns us' do - let(:space_to_unshare) { space } - - it 'responds with 422 and does not unshare the roue' do - api_call.call(space_dev_headers) - - expect(last_response.status).to eq(422) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => "Unable to unshare route '#{route.uri}' from space " \ - "'#{space.guid}'. Routes cannot be removed from the space that owns them.", - 'title' => 'CF-UnprocessableEntity' - } - ) - ) - - route.reload - expect(route.shared_spaces).to contain_exactly(target_space_1, target_space_2, target_space_3) - end - end - - describe 'target space to unshare with' do - context 'does not exist' do - let(:unshared_space_guid) { 'fake-target' } - - it 'responds with 422' do - api_call.call(space_dev_headers) - - expect(last_response.status).to eq(422) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => "Unable to unshare route '#{route.uri}' from space '#{unshared_space_guid}'. " \ - 'Ensure the space exists and that you have access to it.', - 'title' => 'CF-UnprocessableEntity' - } - ) - ) - end - end - - context 'user does not have read access to the target space' do - let(:unshared_space_guid) { VCAP::CloudController::Space.make(organization: org).guid } - - it 'responds with 422 and does not share the route' do - api_call.call(space_dev_headers) - - expect(last_response.status).to eq(422) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => "Unable to unshare route '#{route.uri}' from space '#{unshared_space_guid}'. " \ - 'Ensure the space exists and that you have access to it.', - 'title' => 'CF-UnprocessableEntity' - } - ) - ) - end - end - - context 'user does not have write access to the target space' do - let(:no_write_access_target_space) { VCAP::CloudController::Space.make(organization: org) } - let(:unshared_space_guid) { no_write_access_target_space.guid } - - before do - no_write_access_target_space.add_auditor(user) - end - - it 'responds with 422 and does not share the route' do - api_call.call(space_dev_headers) - - expect(last_response.status).to eq(422) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => "Unable to unshare route '#{route.uri}' from space '#{no_write_access_target_space.guid}'. " \ - "You don't have write permission for the target space.", - 'title' => 'CF-UnprocessableEntity' - } - ) - ) - end - end - end - end - - describe 'PATCH /v3/routes/:guid/relationships/space' do - let(:shared_domain) { VCAP::CloudController::SharedDomain.make } - let(:route) { VCAP::CloudController::Route.make(space: space, domain: shared_domain) } - let(:api_call) { ->(user_headers) { patch "/v3/routes/#{route.guid}/relationships/space", request_body.to_json, user_headers } } - let(:target_space) { VCAP::CloudController::Space.make(organization: org) } - let(:request_body) do - { - data: { 'guid' => target_space.guid } - } - end - let(:space_dev_headers) do - org.add_user(user) - space.add_developer(user) - headers_for(user) - end - let!(:feature_flag) do - VCAP::CloudController::FeatureFlag.make(name: 'route_sharing', enabled: true, error_message: nil) - end - - before do - org.add_user(user) - target_space.add_developer(user) - end - - context 'when the user logged in' do - let(:expected_codes_and_responses) do - h = Hash.new({ code: 403, errors: CF_NOT_AUTHORIZED }.freeze) - h['admin'] = { code: 200 } - h['no_role'] = { code: 404 } - h['org_billing_manager'] = { code: 404 } - h['space_developer'] = { code: 200 } - h - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - - context 'when organization is suspended' do - let(:expected_codes_and_responses) do - h = super() - %w[space_developer].each { |r| h[r] = { code: 403, errors: CF_ORG_SUSPENDED } } - h - end - - before do - org.update(status: VCAP::CloudController::Organization::SUSPENDED) - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - - context 'when target organization is suspended' do - let(:suspended_space) { VCAP::CloudController::Space.make } - let(:request_body) do - { - data: { 'guid' => suspended_space.guid } - } - end - - let(:expected_codes_and_responses) do - h = super() - %w[space_developer].each do |r| - h[r] = { - code: 422, - errors: [{ - detail: "Unable to transfer owner of route '#{route.uri}' to space '#{suspended_space.guid}'. The target organization is suspended.", - title: 'CF-UnprocessableEntity', - code: 10_008 - }] - } - end - h - end - - before do - suspended_space.organization.add_user(user) - suspended_space.add_developer(user) - suspended_space.organization.update(status: VCAP::CloudController::Organization::SUSPENDED) - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - end - - it 'changes the route owner to the given space and logs an event', isolation: :truncation do - api_call.call(space_dev_headers) - - expect(last_response.status).to eq(200) - - event = VCAP::CloudController::Event.last - expect(event.values).to include({ - type: 'audit.route.transfer-owner', - actor: user.guid, - actee_type: 'route', - actee_name: route.host, - space_guid: space.guid, - organization_guid: space.organization.guid - }) - expect(event.metadata['target_space_guid']).to eq(target_space.guid) - - route.reload - expect(route.space).to eq target_space - end - - describe 'when using a private domain' do - let(:private_domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: org) } - let(:route) { VCAP::CloudController::Route.make(space: space, domain: private_domain) } - let(:second_org) { VCAP::CloudController::Organization.make } - let(:another_space) { VCAP::CloudController::Space.make(organization: second_org) } - let(:request_body) do - { - data: { 'guid' => another_space.guid } - } - end - let(:space_dev_headers) do - org.add_user(user) - space.add_developer(user) - second_org.add_user(user) - another_space.add_developer(user) - headers_for(user) - end - - it 'responds with 422' do - api_call.call(space_dev_headers) - - expect(last_response.status).to eq(422) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => "Unable to transfer owner of route '#{route.uri}' to space '#{another_space.guid}'. " \ - "Target space does not have access to route's domain", - 'title' => 'CF-UnprocessableEntity' - } - ) - ) - end - end - - describe 'target space to transfer to' do - context 'does not exist' do - let(:target_space_guid) { 'fake-target' } - let(:request_body) do - { - data: { 'guid' => target_space_guid } - } - end - - it 'responds with 422' do - api_call.call(space_dev_headers) - - expect(last_response.status).to eq(422) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => "Unable to transfer owner of route '#{route.uri}' to space '#{target_space_guid}'. " \ - 'Ensure the space exists and that you have access to it.', - 'title' => 'CF-UnprocessableEntity' - } - ) - ) - end - end - - context 'user does not have read access to the target space' do - let(:no_access_target_space) { VCAP::CloudController::Space.make(organization: org) } - let(:request_body) do - { - data: { 'guid' => no_access_target_space.guid } - } - end - - it 'responds with 422 and does not share the route' do - api_call.call(space_dev_headers) - - expect(last_response.status).to eq(422) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => "Unable to transfer owner of route '#{route.uri}' to space '#{no_access_target_space.guid}'. " \ - 'Ensure the space exists and that you have access to it.', - 'title' => 'CF-UnprocessableEntity' - } - ) - ) - end - end - - context 'user does not have write access to the target space' do - let(:no_write_access_target_space) { VCAP::CloudController::Space.make(organization: org) } - let(:request_body) do - { - data: { 'guid' => no_write_access_target_space.guid } - } - end - - before do - no_write_access_target_space.add_auditor(user) - end - - it 'responds with 422 and does not share the route' do - api_call.call(space_dev_headers) - - expect(last_response.status).to eq(422) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => "Unable to transfer owner of route '#{route.uri}' to space '#{no_write_access_target_space.guid}'. " \ - "You don't have write permission for the target space.", - 'title' => 'CF-UnprocessableEntity' - } - ) - ) - end - end - end - - it 'responds with 404 when the route does not exist' do - patch '/v3/routes/some-fake-guid/relationships/space', request_body.to_json, space_dev_headers - - expect(last_response).to have_status_code(404) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => 'Route not found', - 'title' => 'CF-ResourceNotFound' - } - ) - ) - end - - describe 'when the request body is invalid' do - context 'when there are additional keys' do - let(:request_body) do - { - data: { 'guid' => target_space.guid }, - 'fake-key' => 'foo' - } - end - - it 'responds with 422' do - api_call.call(space_dev_headers) - - expect(last_response.status).to eq(422) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => "Unknown field(s): 'fake-key'", - 'title' => 'CF-UnprocessableEntity' - } - ) - ) - end - end - - context 'when data is not a hash' do - let(:request_body) do - { - data: [{ 'guid' => target_space.guid }] - } - end - - it 'responds with 422' do - api_call.call(space_dev_headers) - - expect(last_response.status).to eq(422) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => 'Data must be an object', - 'title' => 'CF-UnprocessableEntity' - } - ) - ) - end - end - end - - describe 'when route_sharing flag is disabled' do - before do - feature_flag.enabled = false - feature_flag.save - end - - it 'makes users unable to transfer-owner' do - api_call.call(space_dev_headers) - - expect(last_response).to have_status_code(403) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => 'Feature Disabled: route_sharing', - 'title' => 'CF-FeatureDisabled', - 'code' => 330_002 - } - ) - ) - end - end - end -end diff --git a/spec/request/routes/show_spec.rb b/spec/request/routes/show_spec.rb deleted file mode 100644 index d566de16b32..00000000000 --- a/spec/request/routes/show_spec.rb +++ /dev/null @@ -1,172 +0,0 @@ -require 'spec_helper' -require 'request_spec_shared_examples' -require_relative 'shared_context' - -# Split from spec/request/routes_spec.rb for better test parallelization - -RSpec.describe 'Routes Request' do - include_context 'routes request spec' - - describe 'GET /v3/routes/:guid' do - let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } - let(:route) { VCAP::CloudController::Route.make(space:, domain:) } - let(:api_call) { ->(user_headers) { get "/v3/routes/#{route.guid}", nil, user_headers } } - let(:route_json) do - { - guid: route.guid, - protocol: route.domain.protocols[0], - host: route.host, - path: route.path, - port: nil, - url: "#{route.host}.#{route.domain.name}#{route.path}", - created_at: iso8601, - updated_at: iso8601, - destinations: [], - relationships: { - space: { - data: { guid: route.space.guid } - }, - domain: { - data: { guid: route.domain.guid } - } - }, - metadata: { - labels: {}, - annotations: {} - }, - options: {}, - links: { - self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, - space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{route.space.guid}} }, - destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, - domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{route.domain.guid}} } - } - } - end - - context 'when the user is a member in the routes org' do - let(:expected_codes_and_responses) do - h = Hash.new( - { code: 200, - response_object: route_json }.freeze - ) - - h['org_billing_manager'] = { code: 404 } - h['no_role'] = { code: 404 } - h - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - - describe 'when the user is not logged in' do - it 'returns 401 for Unauthenticated requests' do - get "/v3/routes/#{route.guid}", nil, base_json_headers - expect(last_response).to have_status_code(401) - end - end - - describe 'includes' do - context 'when including domains' do - let(:domain_json) do - { - guid: domain.guid, - created_at: iso8601, - updated_at: iso8601, - name: domain.name, - internal: false, - router_group: nil, - supported_protocols: ['http'], - metadata: { - labels: {}, - annotations: {} - }, - relationships: { - organization: { - data: { guid: domain.owning_organization.guid } - }, - shared_organizations: { - data: [] - } - }, - links: { - self: { href: "#{link_prefix}/v3/domains/#{domain.guid}" }, - organization: { href: %r{#{Regexp.escape(link_prefix)}/v3/organizations/#{domain.owning_organization.guid}} }, - route_reservations: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}/route_reservations} }, - shared_organizations: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}/relationships/shared_organizations} } - } - } - end - let(:route_json) do - { - guid: route.guid, - protocol: route.domain.protocols[0], - host: route.host, - path: route.path, - port: nil, - url: "#{route.host}.#{route.domain.name}#{route.path}", - created_at: iso8601, - updated_at: iso8601, - destinations: [], - relationships: { - space: { - data: { guid: route.space.guid } - }, - domain: { - data: { guid: route.domain.guid } - } - }, - metadata: { - labels: {}, - annotations: {} - }, - options: {}, - links: { - self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, - space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{route.space.guid}} }, - destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, - domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{route.domain.guid}} } - }, - included: { domains: [domain_json] } - } - end - - it 'includes the domain for the route' do - get "/v3/routes/#{route.guid}?include=domain", nil, admin_header - expect(last_response).to have_status_code(200), last_response.body - expect(parsed_response).to match_json_response(route_json) - end - end - - context 'when including spaces and orgs' do - it 'includes the unique spaces and organizations for the routes' do - get "/v3/routes/#{route.guid}?include=space,space.organization", nil, admin_header - expect(last_response).to have_status_code(200) - expect(parsed_response['included']).to match_json_response( - 'spaces' => [ - space_json_generator.call(space) - ], - 'organizations' => [ - org_json_generator.call(org) - ] - ) - end - - context 'user is org_auditor' do - let(:user_header) { set_user_with_header_as_role(user: user, role: 'org_auditor', org: org) } - - it 'includes the unique organizations for the routes, but no spaces' do - get "/v3/routes/#{route.guid}?include=space,space.organization", nil, user_header - expect(last_response).to have_status_code(200) - expect(parsed_response['included']).to match_json_response( - 'spaces' => [], - 'organizations' => [ - org_json_generator.call(org) - ] - ) - end - end - end - end - end -end diff --git a/spec/request/routes/update_and_delete_spec.rb b/spec/request/routes/update_and_delete_spec.rb deleted file mode 100644 index 79c60c5ab68..00000000000 --- a/spec/request/routes/update_and_delete_spec.rb +++ /dev/null @@ -1,278 +0,0 @@ -require 'spec_helper' -require 'request_spec_shared_examples' -require_relative 'shared_context' - -# Split from spec/request/routes_spec.rb for better test parallelization - -RSpec.describe 'Routes Request' do - include_context 'routes request spec' - - describe 'PATCH /v3/routes/:guid' do - let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } - let(:route) { VCAP::CloudController::Route.make(space: space, domain: domain, host: '') } - let(:api_call) { ->(user_headers) { patch "/v3/routes/#{route.guid}", params.to_json, user_headers } } - let(:params) do - { - metadata: { - labels: { - potato: 'fingerling', - style: 'roasted' - }, - annotations: { - potato: 'russet', - style: 'fried' - } - } - } - end - - let(:route_json) do - { - guid: UUID_REGEX, - protocol: domain.protocols[0], - host: '', - path: '', - port: nil, - url: domain.name, - created_at: iso8601, - updated_at: iso8601, - destinations: [], - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - }, - links: { - self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, - space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}} }, - destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, - domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } - }, - metadata: { - labels: { - potato: 'fingerling', - style: 'roasted' - }, - annotations: { - potato: 'russet', - style: 'fried' - } - }, - options: {} - } - end - - context 'when the user logged in' do - let(:expected_codes_and_responses) do - h = Hash.new({ code: 403, errors: CF_NOT_AUTHORIZED }.freeze) - h['admin'] = { code: 200, response_object: route_json } - h['no_role'] = { code: 404 } - h['org_billing_manager'] = { code: 404 } - h['space_developer'] = { code: 200, response_object: route_json } - h['space_supporter'] = { code: 200, response_object: route_json } - h - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - - context 'when organization is suspended' do - let(:expected_codes_and_responses) do - h = super() - %w[space_developer space_supporter].each { |r| h[r] = { code: 403, errors: CF_ORG_SUSPENDED } } - h - end - - before do - org.update(status: VCAP::CloudController::Organization::SUSPENDED) - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - end - - context 'when the user is not a member in the routes org' do - let(:other_space) { VCAP::CloudController::Space.make } - let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: other_space.organization) } - let(:route) { VCAP::CloudController::Route.make(space: other_space, domain: domain, host: '') } - - let(:route_json) do - { - guid: UUID_REGEX, - protocol: domain.protocols[0], - host: '', - path: '', - port: nil, - url: domain.name, - created_at: iso8601, - updated_at: iso8601, - destinations: [], - relationships: { - space: { - data: { guid: other_space.guid } - }, - domain: { - data: { guid: domain.guid } - } - }, - links: { - self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, - space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{other_space.guid}} }, - destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, - domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } - }, - metadata: { - labels: { - potato: 'fingerling', - style: 'roasted' - }, - annotations: { - potato: 'russet', - style: 'fried' - } - }, - options: {} - } - end - let(:expected_codes_and_responses) do - h = Hash.new({ code: 404 }.freeze) - h['admin'] = { - code: 200, - response_object: route_json - } - h['admin_read_only'] = { - code: 403 - } - h['global_auditor'] = { - code: 403 - } - h - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - - context 'when route does not exist' do - it 'returns a 404 with a helpful error message' do - patch "/v3/routes/#{user.guid}", params.to_json, admin_header - - expect(last_response).to have_status_code(404) - expect(last_response).to have_error_message('Route not found') - end - end - - context 'when request input message is invalid' do - let(:params_with_invalid_input) do - { - disallowed_key: 'val' - } - end - - it 'returns a 422' do - patch "/v3/routes/#{route.guid}", params_with_invalid_input.to_json, admin_header - - expect(last_response).to have_status_code(422) - end - end - - context 'when metadata is given with invalid format' do - let(:params_with_invalid_metadata_format) do - { - metadata: { - labels: { - "": 'mashed', - '/potato': '.value.' - } - } - } - end - - it 'returns a 422' do - patch "/v3/routes/#{route.guid}", params_with_invalid_metadata_format.to_json, admin_header - - expect(last_response).to have_status_code(422) - expect(parsed_response['errors'][0]['detail']).to match(/Metadata [\w\s]+ error/) - end - end - - context 'when the user is not logged in' do - it 'returns 401 for Unauthenticated requests' do - patch "/v3/routes/#{route.guid}", nil, base_json_headers - expect(last_response).to have_status_code(401) - end - end - end - - describe 'DELETE /v3/routes/:guid' do - let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } - let(:route) { VCAP::CloudController::Route.make(space:, domain:) } - let(:api_call) { ->(user_headers) { delete "/v3/routes/#{route.guid}", nil, user_headers } } - let(:db_check) do - lambda do - expect(last_response.headers['Location']).to match(%r{http.+/v3/jobs/[a-fA-F0-9-]+}) - - execute_all_jobs(expected_successes: 1, expected_failures: 0) - get "/v3/routes/#{route.guid}", {}, admin_headers - expect(last_response).to have_status_code(404) - end - end - - context 'deleting metadata' do - it_behaves_like 'resource with metadata' do - let(:resource) { route } - let(:api_call) do - -> { delete "/v3/routes/#{route.guid}", nil, admin_header } - end - end - end - - context 'when the user is a member in the routes org' do - let(:expected_codes_and_responses) do - h = Hash.new({ code: 403, errors: CF_NOT_AUTHORIZED }.freeze) - h['org_billing_manager'] = { code: 404 } - h['no_role'] = { code: 404 } - h['admin'] = { code: 202 } - h['space_developer'] = { code: 202 } - h['space_supporter'] = { code: 202 } - h - end - - it_behaves_like 'permissions for delete endpoint', ALL_PERMISSIONS do - let(:expected_event_hash) do - { - type: 'audit.route.delete-request', - actee: route.guid, - actee_type: 'route', - actee_name: route.host, - metadata: { request: { recursive: true } }.to_json, - space_guid: space.guid, - organization_guid: org.guid - } - end - end - - context 'when organization is suspended' do - let(:expected_codes_and_responses) do - h = super() - %w[space_developer space_supporter].each { |r| h[r] = { code: 403, errors: CF_ORG_SUSPENDED } } - h - end - - before do - org.update(status: VCAP::CloudController::Organization::SUSPENDED) - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - end - - describe 'when the user is not logged in' do - it 'returns 401 for Unauthenticated requests' do - delete "/v3/routes/#{route.guid}", nil, base_json_headers - expect(last_response).to have_status_code(401) - end - end - end -end diff --git a/spec/request/routes_spec.rb b/spec/request/routes_spec.rb new file mode 100644 index 00000000000..2c2fe30cd84 --- /dev/null +++ b/spec/request/routes_spec.rb @@ -0,0 +1,3748 @@ +require 'spec_helper' +require 'request_spec_shared_examples' +require 'presenters/v3/space_presenter' +require 'presenters/v3/organization_presenter' + +RSpec.describe 'Routes Request' do + let(:user) { VCAP::CloudController::User.make } + let(:admin_header) { admin_headers_for(user) } + let!(:org) { VCAP::CloudController::Organization.make(created_at: 1.hour.ago) } + let!(:space) { VCAP::CloudController::Space.make(name: 'a-space', created_at: 1.hour.ago, organization: org) } + + let(:space_json_generator) do + lambda { |s| + presented_space = VCAP::CloudController::Presenters::V3::SpacePresenter.new(s).to_hash + presented_space[:created_at] = iso8601 + presented_space[:updated_at] = iso8601 + presented_space + } + end + + let(:org_json_generator) do + lambda { |o| + presented_space = VCAP::CloudController::Presenters::V3::OrganizationPresenter.new(o).to_hash + presented_space[:created_at] = iso8601 + presented_space[:updated_at] = iso8601 + presented_space + } + end + + before do + TestConfig.override(kubernetes: {}) + end + + describe 'GET /v3/routes' do + let(:other_space) { VCAP::CloudController::Space.make(name: 'b-space') } + let(:app_model) { VCAP::CloudController::AppModel.make(space:) } + let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } + let!(:route_in_org) do + VCAP::CloudController::Route.make(space: space, domain: domain, host: 'host-1', path: '/path1', guid: 'route-in-org-guid') + end + let!(:route_in_other_org) do + VCAP::CloudController::Route.make(space: other_space, host: 'host-2', path: '/path2', guid: 'route-in-other-org-guid') + end + let!(:route_in_org_dest_web) { VCAP::CloudController::RouteMappingModel.make(app: app_model, route: route_in_org, process_type: 'web') } + let!(:route_in_org_dest_worker) { VCAP::CloudController::RouteMappingModel.make(app: app_model, route: route_in_org, process_type: 'worker') } + let(:api_call) { ->(user_headers) { get '/v3/routes', nil, user_headers } } + let(:route_in_org_json) do + { + guid: route_in_org.guid, + protocol: route_in_org.domain.protocols[0], + host: route_in_org.host, + path: route_in_org.path, + port: nil, + url: "#{route_in_org.host}.#{route_in_org.domain.name}#{route_in_org.path}", + created_at: iso8601, + updated_at: iso8601, + destinations: contain_exactly({ + guid: route_in_org_dest_web.guid, + app: { + guid: app_model.guid, + process: { + type: route_in_org_dest_web.process_type + } + }, + weight: route_in_org_dest_web.weight, + port: route_in_org_dest_web.presented_port, + protocol: 'http1', + created_at: iso8601, + updated_at: iso8601 + }, { + guid: route_in_org_dest_worker.guid, + app: { + guid: app_model.guid, + process: { + type: route_in_org_dest_worker.process_type + } + }, + weight: route_in_org_dest_worker.weight, + port: route_in_org_dest_worker.presented_port, + protocol: 'http1', + created_at: iso8601, + updated_at: iso8601 + }), + relationships: { + space: { + data: { guid: route_in_org.space.guid } + }, + domain: { + data: { guid: route_in_org.domain.guid } + } + }, + metadata: { + labels: {}, + annotations: {} + }, + options: {}, + links: { + self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{route_in_org.guid}} }, + space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{route_in_org.space.guid}} }, + destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{route_in_org.guid}/destinations} }, + domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{route_in_org.domain.guid}} } + } + } + end + + let(:route_in_other_org_json) do + { + guid: route_in_other_org.guid, + protocol: route_in_other_org.domain.protocols[0], + host: route_in_other_org.host, + path: route_in_other_org.path, + port: nil, + url: "#{route_in_other_org.host}.#{route_in_other_org.domain.name}#{route_in_other_org.path}", + created_at: iso8601, + updated_at: iso8601, + destinations: [], + relationships: { + space: { + data: { guid: route_in_other_org.space.guid } + }, + domain: { + data: { guid: route_in_other_org.domain.guid } + } + }, + metadata: { + labels: {}, + annotations: {} + }, + options: {}, + links: { + self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{route_in_other_org.guid}} }, + space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{route_in_other_org.space.guid}} }, + destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{route_in_other_org.guid}/destinations} }, + domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{route_in_other_org.domain.guid}} } + } + } + end + + it_behaves_like 'list_endpoint_with_common_filters' do + let(:resource_klass) { VCAP::CloudController::Route } + let(:api_call) do + ->(headers, filters) { get "/v3/routes?#{filters}", nil, headers } + end + let(:headers) { admin_headers } + end + + describe 'query list parameters' do + it_behaves_like 'list query endpoint' do + let(:request) { 'v3/routes' } + let(:message) { VCAP::CloudController::RoutesListMessage } + let(:user_header) { admin_header } + + let(:params) do + { + page: '2', + per_page: '10', + order_by: 'updated_at', + space_guids: %w[foo bar], + service_instance_guids: %w[baz qux], + organization_guids: %w[foo bar], + domain_guids: %w[foo bar], + app_guids: %w[foo bar], + guids: %w[foo bar], + paths: %w[foo bar], + hosts: 'foo', + ports: 636, + include: 'domain', + label_selector: 'foo,bar', + created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", + updated_ats: { gt: Time.now.utc.iso8601 } + } + end + end + end + + context 'when the user is a member in the routes org' do + let(:expected_codes_and_responses) do + h = Hash.new( + { code: 200, + response_objects: [route_in_org_json] }.freeze + ) + + h['admin'] = { code: 200, response_objects: [route_in_org_json, route_in_other_org_json] } + h['admin_read_only'] = { code: 200, response_objects: [route_in_org_json, route_in_other_org_json] } + h['global_auditor'] = { code: 200, response_objects: [route_in_org_json, route_in_other_org_json] } + + h['org_billing_manager'] = { code: 200, response_objects: [] } + h['no_role'] = { code: 200, response_objects: [] } + h + end + + it_behaves_like 'permissions for list endpoint', ALL_PERMISSIONS + end + + describe 'includes' do + context 'when including domains' do + let(:domain1) { VCAP::CloudController::SharedDomain.make(name: 'first-domain.example.com') } + let(:domain1_json) do + { + guid: domain1.guid, + created_at: iso8601, + updated_at: iso8601, + name: domain1.name, + internal: false, + router_group: nil, + supported_protocols: ['http'], + metadata: { + labels: {}, + annotations: {} + }, + relationships: { + organization: { + data: nil + }, + shared_organizations: { + data: [] + } + }, + links: { + self: { href: "#{link_prefix}/v3/domains/#{domain1.guid}" }, + route_reservations: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain1.guid}/route_reservations} } + } + } + end + + let(:domain2) { VCAP::CloudController::SharedDomain.make(name: 'second-domain.example.com') } + let(:domain2_json) do + { + guid: domain2.guid, + created_at: iso8601, + updated_at: iso8601, + name: domain2.name, + internal: false, + router_group: nil, + supported_protocols: ['http'], + metadata: { + labels: {}, + annotations: {} + }, + relationships: { + organization: { + data: nil + }, + shared_organizations: { + data: [] + } + }, + links: { + self: { href: "#{link_prefix}/v3/domains/#{domain1.guid}" }, + route_reservations: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain1.guid}/route_reservations} } + } + } + end + + let(:domain2) { VCAP::CloudController::SharedDomain.make(name: 'second-domain.example.com') } + let(:domain2_json) do + { + guid: domain2.guid, + created_at: iso8601, + updated_at: iso8601, + name: domain2.name, + internal: false, + router_group: nil, + supported_protocols: ['http'], + metadata: { + labels: {}, + annotations: {} + }, + relationships: { + organization: { + data: nil + }, + shared_organizations: { + data: [] + } + }, + links: { + self: { href: "#{link_prefix}/v3/domains/#{domain2.guid}" }, + route_reservations: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain2.guid}/route_reservations} } + } + } + end + + let!(:route1_domain1) do + VCAP::CloudController::Route.make(space: space, host: 'route1', domain: domain1, path: '/path1', guid: 'route1-guid') + end + let(:route1_domain1_json) do + { + guid: route1_domain1.guid, + protocol: route1_domain1.domain.protocols[0], + created_at: iso8601, + updated_at: iso8601, + host: route1_domain1.host, + path: route1_domain1.path, + port: nil, + url: "#{route1_domain1.host}.#{domain1.name}#{route1_domain1.path}", + destinations: [], + metadata: { + labels: {}, + annotations: {} + }, + relationships: { + space: { + data: { + guid: space.guid + } + }, + domain: { + data: { + guid: domain1.guid + } + } + }, + options: {}, + links: { + self: { href: "http://api2.vcap.me/v3/routes/#{route1_domain1.guid}" }, + space: { href: "http://api2.vcap.me/v3/spaces/#{space.guid}" }, + destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{route1_domain1.guid}/destinations} }, + domain: { href: "http://api2.vcap.me/v3/domains/#{domain1.guid}" } + } + } + end + + let!(:route_in_org) do + VCAP::CloudController::Route.make(space: space, domain: domain1, host: 'host-1', path: '/path1', guid: 'route-in-org-guid') + end + let!(:route_in_other_org) do + VCAP::CloudController::Route.make(space: other_space, domain: domain2, host: 'host-2', path: '/path2', guid: 'route-in-other-org-guid') + end + + it 'includes the unique domains for the routes' do + get '/v3/routes?include=domain', nil, admin_header + expect(last_response).to have_status_code(200) + expect({ + resources: parsed_response['resources'], + included: parsed_response['included'] + }).to match_json_response({ + resources: [route_in_org_json, route_in_other_org_json, route1_domain1_json], + included: { 'domains' => [domain1_json, domain2_json] } + }) + end + end + + context 'when including spaces and orgs' do + it 'includes the unique spaces and organizations for the routes' do + get '/v3/routes?include=space,space.organization', nil, admin_header + expect(last_response).to have_status_code(200) + expect({ + resources: parsed_response['resources'], + included: parsed_response['included'] + }).to match_json_response({ + resources: [route_in_org_json, route_in_other_org_json], + included: { + 'spaces' => [ + space_json_generator.call(space), + space_json_generator.call(other_space) + ], + 'organizations' => [ + org_json_generator.call(org), + org_json_generator.call(other_space.organization) + ] + } + }) + end + end + + context 'when including spaces' do + it 'eagerly loads spaces to efficiently access space_guid' do + expect(VCAP::CloudController::IncludeSpaceDecorator).to receive(:decorate) do |_, resources| + expect(resources).not_to be_empty + resources.each { |r| expect(r.associations).to include(:space) } + end + + get '/v3/routes?include=space', nil, admin_header + expect(last_response).to have_status_code(200) + end + end + + context 'when including orgs' do + it 'eagerly loads spaces to efficiently access space.organization_id' do + expect(VCAP::CloudController::IncludeOrganizationDecorator).to receive(:decorate) do |_, resources| + expect(resources).not_to be_empty + resources.each { |r| expect(r.associations).to include(:space) } + end + + get '/v3/routes?include=space.organization', nil, admin_header + expect(last_response).to have_status_code(200) + end + end + end + + describe 'filters' do + let!(:route_without_host_and_with_path) do + VCAP::CloudController::Route.make(space: space, host: '', domain: domain, path: '/path1', guid: 'route-without-host') + end + let!(:route_without_host_and_with_path2) do + VCAP::CloudController::Route.make(space: space, host: '', domain: domain, path: '/path2', guid: 'route-without-host2') + end + let(:route_without_host_and_with_path_json) do + { + guid: 'route-without-host', + protocol: domain.protocols[0], + created_at: iso8601, + updated_at: iso8601, + destinations: [], + host: '', + path: '/path1', + port: nil, + url: "#{domain.name}/path1", + metadata: { + labels: {}, + annotations: {} + }, + relationships: { + space: { + data: { + guid: space.guid + } + }, + domain: { + data: { + guid: domain.guid + } + } + }, + options: {}, + links: { + self: { href: 'http://api2.vcap.me/v3/routes/route-without-host' }, + space: { href: "http://api2.vcap.me/v3/spaces/#{space.guid}" }, + destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/route-without-host/destinations} }, + domain: { href: "http://api2.vcap.me/v3/domains/#{domain.guid}" } + } + } + end + let(:route_without_host_and_with_path2_json) do + { + guid: 'route-without-host2', + protocol: domain.protocols[0], + created_at: iso8601, + updated_at: iso8601, + destinations: [], + host: '', + path: '/path2', + port: nil, + url: "#{domain.name}/path2", + metadata: { + labels: {}, + annotations: {} + }, + relationships: { + space: { + data: { + guid: space.guid + } + }, + domain: { + data: { + guid: domain.guid + } + } + }, + options: {}, + links: { + self: { href: 'http://api2.vcap.me/v3/routes/route-without-host2' }, + space: { href: "http://api2.vcap.me/v3/spaces/#{space.guid}" }, + destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/route-without-host2/destinations} }, + domain: { href: "http://api2.vcap.me/v3/domains/#{domain.guid}" } + } + } + end + let!(:route_without_path_and_with_host) do + VCAP::CloudController::Route.make(space: space, host: 'host-1', domain: domain, path: '', guid: 'route-without-path') + end + let(:route_without_path_and_with_host_json) do + { + guid: 'route-without-path', + protocol: domain.protocols[0], + created_at: iso8601, + updated_at: iso8601, + destinations: [], + host: 'host-1', + path: '', + port: nil, + url: "host-1.#{domain.name}", + metadata: { + labels: {}, + annotations: {} + }, + relationships: { + space: { + data: { + guid: space.guid + } + }, + domain: { + data: { + guid: domain.guid + } + } + }, + options: {}, + links: { + self: { href: 'http://api2.vcap.me/v3/routes/route-without-path' }, + space: { href: "http://api2.vcap.me/v3/spaces/#{space.guid}" }, + destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/route-without-path/destinations} }, + domain: { href: "http://api2.vcap.me/v3/domains/#{domain.guid}" } + } + } + end + + context 'hosts filter' do + it 'returns routes filtered by host' do + get '/v3/routes?hosts=host-1', nil, admin_header + expect(last_response).to have_status_code(200) + expect({ + resources: parsed_response['resources'] + }).to match_json_response({ + resources: [route_in_org_json, route_without_path_and_with_host_json] + }) + end + + it 'returns route with no host if one exists when filtering by empty host' do + get '/v3/routes?hosts=', nil, admin_header + expect(last_response).to have_status_code(200) + expect({ + resources: parsed_response['resources'] + }).to match_json_response({ + resources: [route_without_host_and_with_path_json, route_without_host_and_with_path2_json] + }) + end + end + + context 'paths filter' do + it 'returns routes filtered by path' do + get '/v3/routes?paths=%2Fpath1', nil, admin_header + expect(last_response).to have_status_code(200) + expect({ + resources: parsed_response['resources'] + }).to match_json_response({ + resources: [route_in_org_json, route_without_host_and_with_path_json] + }) + end + + it 'returns route with no path when filtering by empty path' do + get '/v3/routes?paths=', nil, admin_header + expect(last_response).to have_status_code(200) + expect({ + resources: parsed_response['resources'] + }).to match_json_response({ + resources: [route_without_path_and_with_host_json] + }) + end + end + + context 'hosts and paths filter' do + it 'returns routes with no host and the provided path when host is empty' do + get '/v3/routes?paths=%2Fpath1&hosts=', nil, admin_header + expect(last_response).to have_status_code(200) + expect({ + resources: parsed_response['resources'] + }).to match_json_response({ + resources: [route_without_host_and_with_path_json] + }) + end + end + + context 'organization_guids filter' do + it 'returns routes filtered by organization_guid' do + get "/v3/routes?organization_guids=#{other_space.organization.guid}", nil, admin_header + expect(last_response).to have_status_code(200) + expect({ + resources: parsed_response['resources'] + }).to match_json_response({ + resources: [route_in_other_org_json] + }) + end + end + + context 'space_guids filter' do + it 'returns routes filtered by space_guid' do + get "/v3/routes?space_guids=#{other_space.guid}", nil, admin_header + expect(last_response).to have_status_code(200) + expect({ + resources: parsed_response['resources'] + }).to match_json_response({ + resources: [route_in_other_org_json] + }) + end + end + + context 'domain_guids filter' do + it 'returns routes filtered by domain_guid' do + get "/v3/routes?domain_guids=#{route_in_other_org.domain.guid}", nil, admin_header + expect(last_response).to have_status_code(200) + expect({ + resources: parsed_response['resources'] + }).to match_json_response({ + resources: [route_in_other_org_json] + }) + end + end + + context 'app_guids filter' do + it 'returns routes filtered by app_guid' do + get "/v3/routes?app_guids=#{app_model.guid}", nil, admin_header + expect(last_response).to have_status_code(200) + expect(parsed_response['resources'].size).to eq(1) + expect(parsed_response['resources'].first['destinations'].size).to eq(2) + expect( + parsed_response['resources'].first['destinations'].map { |destination| destination['app']['guid'] }.uniq + ).to eq([app_model.guid]) + end + end + + context 'ports filter' do + # Don't even think of converting the following hash to symbols ('type' => 'tcp' NOT type: 'tcp'), and you need to set the GUID + let(:router_group) { VCAP::CloudController::RoutingApi::RouterGroup.new({ 'type' => 'tcp', 'reservable_ports' => '7777,8888,9999', 'guid' => 'some-guid' }) } + let(:routing_api_client) { instance_double(VCAP::CloudController::RoutingApi::Client) } + + before do + allow_any_instance_of(CloudController::DependencyLocator).to receive(:routing_api_client).and_return(routing_api_client) + allow(routing_api_client).to receive_messages(enabled?: true, router_group: router_group) + end + + context 'when there are multiple TCP routes with different ports' do + # The following `let`s depend on the above `before do` + let(:domain_tcp) { VCAP::CloudController::SharedDomain.make(router_group_guid: router_group.guid, name: 'my.domain') } + let!(:route_with_ports_0) do + VCAP::CloudController::Route.make(host: '', space: space, domain: domain_tcp, guid: 'route-with-port-0', port: 7777) + end + let!(:route_with_ports_1) do + VCAP::CloudController::Route.make(host: '', space: space, domain: domain_tcp, guid: 'route-with-port-1', port: 8888) + end + let!(:route_with_ports_2) do + VCAP::CloudController::Route.make(host: '', space: space, domain: domain_tcp, guid: 'route-with-port-2', port: 9999) + end + + it 'returns routes filtered by ports' do + get '/v3/routes?ports=7777,8888', nil, admin_header + expect(last_response).to have_status_code(200) + expect(parsed_response['resources'].size).to eq(2) + expect(parsed_response['resources'].pluck('port')).to contain_exactly(route_with_ports_0.port, route_with_ports_1.port) + end + end + end + + context 'service instance guids filter' do + let(:service_instance_one) do + VCAP::CloudController::ManagedServiceInstance.make(:routing, space: space, name: 'si-name-1') + end + let(:service_instance_two) do + VCAP::CloudController::ManagedServiceInstance.make(:routing, space: space, name: 'si-name-2') + end + + let!(:route_with_service_instance_one) do + VCAP::CloudController::Route.make(space: space, host: 'host-with-service-instance-one', domain: domain, path: '/path1', guid: 'route-with-service-instance-one') + end + let!(:route_with_service_instance_two) do + VCAP::CloudController::Route.make(space: space, host: 'host-with-service-instance-two', domain: domain, path: '/path2', guid: 'route-with-service-instance-two') + end + + let!(:route_mapping_one) { VCAP::CloudController::RouteBinding.make(route: route_with_service_instance_one, service_instance: service_instance_one) } + let!(:route_mapping_two) { VCAP::CloudController::RouteBinding.make(route: route_with_service_instance_two, service_instance: service_instance_two) } + + it 'returns routes filtered by service instance guid' do + get "/v3/routes?service_instance_guids=#{service_instance_one.guid},#{service_instance_two.guid}", nil, admin_header + expect(last_response).to have_status_code(200) + expect(parsed_response['resources'].size).to eq(2) + expect(parsed_response['resources'].pluck('guid')).to contain_exactly('route-with-service-instance-one', 'route-with-service-instance-two') + end + end + end + + describe 'labels' do + let!(:domain1) { VCAP::CloudController::PrivateDomain.make(name: 'dom1.com', owning_organization: org) } + let!(:route1) { VCAP::CloudController::Route.make(space: space, domain: domain1, host: 'hall', path: '/oates', guid: 'guid-1') } + let!(:route1_label) { VCAP::CloudController::RouteLabelModel.make(resource_guid: route1.guid, key_name: 'animal', value: 'dog') } + + let!(:domain2) { VCAP::CloudController::PrivateDomain.make(name: 'dom2.com', owning_organization: org) } + let!(:route2) { VCAP::CloudController::Route.make(space: space, domain: domain2, guid: 'guid-2') } + let!(:route2_label) { VCAP::CloudController::RouteLabelModel.make(resource_guid: route2.guid, key_name: 'animal', value: 'cow') } + let!(:route2__exclusive_label) { VCAP::CloudController::RouteLabelModel.make(resource_guid: route2.guid, key_name: 'santa', value: 'claus') } + + describe 'label_selectors' do + it 'returns a 200 and the filtered routes for "in" label selector' do + get '/v3/routes?label_selector=animal in (dog)', nil, admin_header + + expect(last_response).to have_status_code(200), last_response.body + parsed_response = Oj.load(last_response.body) + + expected_pagination = { + 'total_results' => 1, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal+in+%28dog%29&page=1&per_page=50" }, + 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal+in+%28dog%29&page=1&per_page=50" }, + 'next' => nil, + 'previous' => nil + } + + expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route1.guid) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + + it 'returns a 200 and the filtered routes for "in" label selector with space guids' do + get "/v3/routes?label_selector=animal in (dog)&space_guids=#{space.guid}", nil, admin_header + + expect(last_response).to have_status_code(200) + parsed_response = Oj.load(last_response.body) + + expected_pagination = { + 'total_results' => 1, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal+in+%28dog%29&page=1&per_page=50&space_guids=#{space.guid}" }, + 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal+in+%28dog%29&page=1&per_page=50&space_guids=#{space.guid}" }, + 'next' => nil, + 'previous' => nil + } + + expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route1.guid) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + + it 'returns a 200 and the filtered routes for "in" label selector with org filters' do + get "/v3/routes?label_selector=animal in (dog)&organization_guids=#{org.guid}", nil, admin_header + + expect(last_response).to have_status_code(200), last_response.body + parsed_response = Oj.load(last_response.body) + + expected_pagination = { + 'total_results' => 1, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal+in+%28dog%29&organization_guids=#{org.guid}&page=1&per_page=50" }, + 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal+in+%28dog%29&organization_guids=#{org.guid}&page=1&per_page=50" }, + 'next' => nil, + 'previous' => nil + } + + expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route1.guid) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + + it 'returns a 200 and the filtered routes for "in" label selector with domain filters' do + get "/v3/routes?label_selector=animal in (dog)&domain_guids=#{domain1.guid}", nil, admin_header + + expect(last_response).to have_status_code(200), last_response.body + parsed_response = Oj.load(last_response.body) + + expected_pagination = { + 'total_results' => 1, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/routes?domain_guids=#{domain1.guid}&label_selector=animal+in+%28dog%29&page=1&per_page=50" }, + 'last' => { 'href' => "#{link_prefix}/v3/routes?domain_guids=#{domain1.guid}&label_selector=animal+in+%28dog%29&page=1&per_page=50" }, + 'next' => nil, + 'previous' => nil + } + + expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route1.guid) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + + it 'returns a 200 and the filtered routes for "in" label selector with host filters' do + get '/v3/routes?label_selector=animal in (dog)&hosts=hall', nil, admin_header + + expect(last_response).to have_status_code(200), last_response.body + parsed_response = Oj.load(last_response.body) + + expected_pagination = { + 'total_results' => 1, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/routes?hosts=hall&label_selector=animal+in+%28dog%29&page=1&per_page=50" }, + 'last' => { 'href' => "#{link_prefix}/v3/routes?hosts=hall&label_selector=animal+in+%28dog%29&page=1&per_page=50" }, + 'next' => nil, + 'previous' => nil + } + + expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route1.guid) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + + it 'returns a 200 and the filtered routes for "in" label selector with path filters' do + get '/v3/routes?label_selector=animal in (dog)&paths=/oates', nil, admin_header + + expect(last_response).to have_status_code(200) + parsed_response = Oj.load(last_response.body) + + expected_pagination = { + 'total_results' => 1, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal+in+%28dog%29&page=1&paths=%2Foates&per_page=50" }, + 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal+in+%28dog%29&page=1&paths=%2Foates&per_page=50" }, + 'next' => nil, + 'previous' => nil + } + + expect(last_response).to have_status_code(200) + expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route1.guid) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + end + + it 'returns a 200 and the filtered routes for "notin" label selector' do + get '/v3/routes?label_selector=animal notin (dog)', nil, admin_header + + parsed_response = Oj.load(last_response.body) + + expected_pagination = { + 'total_results' => 3, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal+notin+%28dog%29&page=1&per_page=50" }, + 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal+notin+%28dog%29&page=1&per_page=50" }, + 'next' => nil, + 'previous' => nil + } + + expect(last_response).to have_status_code(200) + expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route2.guid, route_in_org.guid, route_in_other_org.guid) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + + it 'returns a 200 and the filtered routes for "=" label selector' do + get '/v3/routes?label_selector=animal=dog', nil, admin_header + + parsed_response = Oj.load(last_response.body) + + expected_pagination = { + 'total_results' => 1, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal%3Ddog&page=1&per_page=50" }, + 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal%3Ddog&page=1&per_page=50" }, + 'next' => nil, + 'previous' => nil + } + + expect(last_response).to have_status_code(200) + expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route1.guid) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + + it 'returns a 200 and the filtered domains for "==" label selector' do + get '/v3/routes?label_selector=animal==dog', nil, admin_header + + parsed_response = Oj.load(last_response.body) + + expected_pagination = { + 'total_results' => 1, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal%3D%3Ddog&page=1&per_page=50" }, + 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal%3D%3Ddog&page=1&per_page=50" }, + 'next' => nil, + 'previous' => nil + } + + expect(last_response).to have_status_code(200) + expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route1.guid) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + + it 'returns a 200 and the filtered routes for "!=" label selector' do + get '/v3/routes?label_selector=animal!=dog', nil, admin_header + + parsed_response = Oj.load(last_response.body) + + expected_pagination = { + 'total_results' => 3, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal%21%3Ddog&page=1&per_page=50" }, + 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal%21%3Ddog&page=1&per_page=50" }, + 'next' => nil, + 'previous' => nil + } + + expect(last_response).to have_status_code(200) + expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route2.guid, route_in_org.guid, route_in_other_org.guid) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + + it 'returns a 200 and the filtered routes for "=" label selector' do + get '/v3/routes?label_selector=animal=cow,santa=claus', nil, admin_header + + parsed_response = Oj.load(last_response.body) + + expected_pagination = { + 'total_results' => 1, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal%3Dcow%2Csanta%3Dclaus&page=1&per_page=50" }, + 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal%3Dcow%2Csanta%3Dclaus&page=1&per_page=50" }, + 'next' => nil, + 'previous' => nil + } + + expect(last_response).to have_status_code(200) + expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route2.guid) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + + it 'returns a 200 and the filtered routes for existence label selector' do + get '/v3/routes?label_selector=santa', nil, admin_header + + parsed_response = Oj.load(last_response.body) + + expected_pagination = { + 'total_results' => 1, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=santa&page=1&per_page=50" }, + 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=santa&page=1&per_page=50" }, + 'next' => nil, + 'previous' => nil + } + + expect(last_response).to have_status_code(200) + expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route2.guid) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + + it 'returns a 200 and the filtered routes for non-existence label selector' do + get '/v3/routes?label_selector=!santa', nil, admin_header + + parsed_response = Oj.load(last_response.body) + + expected_pagination = { + 'total_results' => 3, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=%21santa&page=1&per_page=50" }, + 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=%21santa&page=1&per_page=50" }, + 'next' => nil, + 'previous' => nil + } + + expect(last_response).to have_status_code(200) + expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route1.guid, route_in_org.guid, route_in_other_org.guid) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + end + + describe 'eager loading' do + it 'eager loads associated resources that the presenter specifies' do + expect(VCAP::CloudController::RouteFetcher).to receive(:fetch).with( + anything, + hash_including(eager_loaded_associations: %i[domain space route_mappings labels annotations]) + ).and_call_original + + get '/v3/routes', nil, admin_header + expect(last_response).to have_status_code(200) + end + end + + context 'when the request is invalid' do + it 'returns 400 with a meaningful error' do + get '/v3/routes?page=potato', nil, admin_header + expect(last_response).to have_status_code(400) + expect(last_response).to have_error_message('The query parameter is invalid: Page must be a positive integer') + end + end + + context 'when the user is not logged in' do + it 'returns 401 for Unauthenticated requests' do + get '/v3/routes', nil, base_json_headers + expect(last_response).to have_status_code(401) + end + end + end + + describe 'GET /v3/routes/:guid' do + let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } + let(:route) { VCAP::CloudController::Route.make(space:, domain:) } + let(:api_call) { ->(user_headers) { get "/v3/routes/#{route.guid}", nil, user_headers } } + let(:route_json) do + { + guid: route.guid, + protocol: route.domain.protocols[0], + host: route.host, + path: route.path, + port: nil, + url: "#{route.host}.#{route.domain.name}#{route.path}", + created_at: iso8601, + updated_at: iso8601, + destinations: [], + relationships: { + space: { + data: { guid: route.space.guid } + }, + domain: { + data: { guid: route.domain.guid } + } + }, + metadata: { + labels: {}, + annotations: {} + }, + options: {}, + links: { + self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, + space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{route.space.guid}} }, + destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, + domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{route.domain.guid}} } + } + } + end + + context 'when the user is a member in the routes org' do + let(:expected_codes_and_responses) do + h = Hash.new( + { code: 200, + response_object: route_json }.freeze + ) + + h['org_billing_manager'] = { code: 404 } + h['no_role'] = { code: 404 } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + + describe 'when the user is not logged in' do + it 'returns 401 for Unauthenticated requests' do + get "/v3/routes/#{route.guid}", nil, base_json_headers + expect(last_response).to have_status_code(401) + end + end + + describe 'includes' do + context 'when including domains' do + let(:domain_json) do + { + guid: domain.guid, + created_at: iso8601, + updated_at: iso8601, + name: domain.name, + internal: false, + router_group: nil, + supported_protocols: ['http'], + metadata: { + labels: {}, + annotations: {} + }, + relationships: { + organization: { + data: { guid: domain.owning_organization.guid } + }, + shared_organizations: { + data: [] + } + }, + links: { + self: { href: "#{link_prefix}/v3/domains/#{domain.guid}" }, + organization: { href: %r{#{Regexp.escape(link_prefix)}/v3/organizations/#{domain.owning_organization.guid}} }, + route_reservations: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}/route_reservations} }, + shared_organizations: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}/relationships/shared_organizations} } + } + } + end + let(:route_json) do + { + guid: route.guid, + protocol: route.domain.protocols[0], + host: route.host, + path: route.path, + port: nil, + url: "#{route.host}.#{route.domain.name}#{route.path}", + created_at: iso8601, + updated_at: iso8601, + destinations: [], + relationships: { + space: { + data: { guid: route.space.guid } + }, + domain: { + data: { guid: route.domain.guid } + } + }, + metadata: { + labels: {}, + annotations: {} + }, + options: {}, + links: { + self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, + space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{route.space.guid}} }, + destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, + domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{route.domain.guid}} } + }, + included: { domains: [domain_json] } + } + end + + it 'includes the domain for the route' do + get "/v3/routes/#{route.guid}?include=domain", nil, admin_header + expect(last_response).to have_status_code(200), last_response.body + expect(parsed_response).to match_json_response(route_json) + end + end + + context 'when including spaces and orgs' do + it 'includes the unique spaces and organizations for the routes' do + get "/v3/routes/#{route.guid}?include=space,space.organization", nil, admin_header + expect(last_response).to have_status_code(200) + expect(parsed_response['included']).to match_json_response( + 'spaces' => [ + space_json_generator.call(space) + ], + 'organizations' => [ + org_json_generator.call(org) + ] + ) + end + + context 'user is org_auditor' do + let(:user_header) { set_user_with_header_as_role(user: user, role: 'org_auditor', org: org) } + + it 'includes the unique organizations for the routes, but no spaces' do + get "/v3/routes/#{route.guid}?include=space,space.organization", nil, user_header + expect(last_response).to have_status_code(200) + expect(parsed_response['included']).to match_json_response( + 'spaces' => [], + 'organizations' => [ + org_json_generator.call(org) + ] + ) + end + end + end + end + end + + describe 'POST /v3/routes' do + context 'when creating a route in a tcp domain' do + let(:domain) { VCAP::CloudController::SharedDomain.make(router_group_guid: 'some-router-group', router_group_type: 'tcp') } + + before do + token = { token_type: 'Bearer', access_token: 'my-favourite-access-token' } + stub_request(:post, 'https://uaa.service.cf.internal/oauth/token'). + to_return(status: 200, body: token.to_json, headers: { 'Content-Type' => 'application/json' }) + stub_request(:get, 'http://localhost:3000/routing/v1/router_groups'). + to_return(status: 200, body: '[{"guid":"some-router-group","name":"Robby Router","type":"tcp","reservable_ports":"25555"}]', headers: {}) + end + + context 'and the route has a host' do + let(:params) do + { + host: 'my-host', + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + it 'returns a 422 and a helpful error message' do + post '/v3/routes', params.to_json, admin_header + expect(last_response).to have_status_code(422) + expect(last_response).to have_error_message('Hosts are not supported for TCP routes.') + end + end + + context 'and the route has a path' do + let(:params) do + { + path: '/cgi-bin', + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + it 'returns a 422 and a helpful error message' do + post '/v3/routes', params.to_json, admin_header + expect(last_response).to have_status_code(422) + expect(last_response).to have_error_message('Paths are not supported for TCP routes.') + end + end + end + + context 'when creating a route in a scoped domain' do + let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } + + describe 'when creating a route without a host' do + let(:params) do + { + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + let(:route_json) do + { + guid: UUID_REGEX, + protocol: domain.protocols[0], + host: '', + path: '', + port: nil, + url: domain.name, + created_at: iso8601, + updated_at: iso8601, + destinations: [], + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + }, + links: { + self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, + space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}} }, + destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, + domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } + }, + metadata: { + labels: {}, + annotations: {} + }, + options: {} + } + end + + describe 'valid routes' do + let(:api_call) { ->(user_headers) { post '/v3/routes', params.to_json, user_headers } } + + let(:expected_codes_and_responses) do + h = Hash.new( + { code: 403 }.freeze + ) + h['admin'] = { + code: 201, + response_object: route_json + } + h['space_developer'] = { + code: 201, + response_object: route_json + } + h['space_supporter'] = { + code: 201, + response_object: route_json + } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + end + + describe 'when creating a route with a host' do + let(:params) do + { + host: 'some-host', + path: '/some-path', + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + }, + metadata: { + labels: { potato: 'yam' }, + annotations: { style: 'mashed' } + }, + options: {} + } + end + + let(:route_json) do + { + guid: UUID_REGEX, + protocol: domain.protocols[0], + host: 'some-host', + path: '/some-path', + port: nil, + url: "some-host.#{domain.name}/some-path", + created_at: iso8601, + updated_at: iso8601, + destinations: [], + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + }, + links: { + self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, + space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}} }, + destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, + domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } + }, + metadata: { + labels: { potato: 'yam' }, + annotations: { style: 'mashed' } + }, + options: {} + } + end + + describe 'valid routes' do + it_behaves_like 'permissions for single object endpoint', ['admin'] do + let(:api_call) { ->(user_headers) { post '/v3/routes', params.to_json, user_headers } } + + let(:expected_codes_and_responses) do + h = Hash.new( + { code: 403 }.freeze + ) + h['admin'] = { + code: 201, + response_object: route_json + } + h['space_developer'] = { + code: 201, + response_object: route_json + } + h['space_supporter'] = { + code: 201, + response_object: route_json + } + h + end + + let(:expected_event_hash) do + { + type: 'audit.route.create', + actee: parsed_response['guid'], + actee_type: 'route', + actee_name: 'some-host', + metadata: { request: params }.to_json, + space_guid: space.guid, + organization_guid: org.guid + } + end + end + end + end + + describe 'when creating a route with a wildcard host' do + let(:params) do + { + host: '*', + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + let(:route_json) do + { + guid: UUID_REGEX, + protocol: domain.protocols[0], + host: '*', + path: '', + port: nil, + url: "*.#{domain.name}", + created_at: iso8601, + updated_at: iso8601, + destinations: [], + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + }, + links: { + self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, + space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}} }, + destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, + domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } + }, + metadata: { + labels: {}, + annotations: {} + }, + options: {} + } + end + + describe 'valid routes' do + let(:api_call) { ->(user_headers) { post '/v3/routes', params.to_json, user_headers } } + + let(:expected_codes_and_responses) do + h = Hash.new( + { code: 403 }.freeze + ) + h['admin'] = { + code: 201, + response_object: route_json + } + h['space_developer'] = { + code: 201, + response_object: route_json + } + h['space_supporter'] = { + code: 201, + response_object: route_json + } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + end + end + + context 'when creating a route in an unscoped domain' do + let(:domain) { VCAP::CloudController::SharedDomain.make } + + describe 'when creating a route without a host' do + let(:params) do + { + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + it 'fails with a helpful message' do + post '/v3/routes', params.to_json, admin_header + expect(last_response).to have_status_code(422) + expect(last_response).to have_error_message('Missing host. Routes in shared domains must have a host defined.') + end + end + + describe 'when creating a route with a host' do + let(:params) do + { + host: 'some-host', + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + let(:route_json) do + { + guid: UUID_REGEX, + protocol: domain.protocols[0], + host: 'some-host', + path: '', + port: nil, + url: "some-host.#{domain.name}", + created_at: iso8601, + updated_at: iso8601, + destinations: [], + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + }, + links: { + self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, + space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}} }, + destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, + domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } + }, + metadata: { + labels: {}, + annotations: {} + }, + options: {} + } + end + + describe 'valid routes' do + let(:api_call) { ->(user_headers) { post '/v3/routes', params.to_json, user_headers } } + + let(:expected_codes_and_responses) do + h = Hash.new( + { code: 403 }.freeze + ) + h['admin'] = { + code: 201, + response_object: route_json + } + h['space_developer'] = { + code: 201, + response_object: route_json + } + h['space_supporter'] = { + code: 201, + response_object: route_json + } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + end + + describe 'when creating a route with a wildcard host' do + let(:params) do + { + host: '*', + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + let(:route_json) do + { + guid: UUID_REGEX, + protocol: domain.protocols[0], + host: '*', + path: '', + port: nil, + url: "*.#{domain.name}", + created_at: iso8601, + updated_at: iso8601, + destinations: [], + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + }, + links: { + self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, + space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}} }, + destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, + domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } + }, + metadata: { + labels: {}, + annotations: {} + }, + options: {} + } + end + + describe 'valid routes' do + let(:api_call) { ->(user_headers) { post '/v3/routes', params.to_json, user_headers } } + + let(:expected_codes_and_responses) do + h = Hash.new( + { code: 403 }.freeze + ) + h['admin'] = { + code: 201, + response_object: route_json + } + h['space_developer'] = { + code: 422 + } + h['space_supporter'] = { + code: 422 + } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + end + + describe 'the domain supports tcp routes' do + let(:router_group) { VCAP::CloudController::RoutingApi::RouterGroup.new({ 'type' => 'tcp', 'reservable_ports' => '123' }) } + let(:domain) { VCAP::CloudController::SharedDomain.make(router_group_guid: 'some-router-group', name: 'my.domain') } + let(:routing_api_client) { instance_double(VCAP::CloudController::RoutingApi::Client) } + + before do + TestConfig.override( + kubernetes: { host_url: nil }, + external_domain: 'api2.vcap.me', + external_protocol: 'https' + ) + allow_any_instance_of(CloudController::DependencyLocator).to receive(:routing_api_client).and_return(routing_api_client) + allow(routing_api_client).to receive_messages(enabled?: true, router_group: router_group) + end + + let(:params) do + { + port: 123, + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + let(:api_call) { ->(user_headers) { post '/v3/routes', params.to_json, user_headers } } + + let(:route_json) do + { + guid: UUID_REGEX, + port: 123, + host: '', + path: '', + protocol: 'tcp', + url: "#{domain.name}:123", + created_at: iso8601, + updated_at: iso8601, + destinations: [], + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + }, + links: { + self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, + space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}} }, + destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, + domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } + }, + metadata: { + labels: {}, + annotations: {} + }, + options: {} + } + end + + let(:expected_codes_and_responses) do + h = Hash.new( + { code: 403 }.freeze + ) + h['admin'] = { + code: 201, + response_object: route_json + } + h['space_developer'] = { + code: 201, + response_object: route_json + } + h['space_supporter'] = { + code: 201, + response_object: route_json + } + h + end + + context 'and the user provides a valid port' do + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + + context 'and a route with the domain and port already exist' do + let!(:duplicate_route) { VCAP::CloudController::Route.make(host: '', space: space, domain: domain, port: 123) } + + it 'fails with a helpful error message' do + post '/v3/routes', params.to_json, admin_headers + expect(last_response).to have_status_code(422) + expect(last_response).to have_error_message("Route already exists with port '123' for domain 'my.domain'.") + end + end + + context 'and the port is already in use for the router group' do + let!(:other_domain) { VCAP::CloudController::SharedDomain.make(router_group_guid: 'some-router-group', name: 'my.domain2') } + let!(:route_with_port) { VCAP::CloudController::Route.make(host: '', space: space, domain: other_domain, port: 123) } + + it 'fails with a helpful error message' do + post '/v3/routes', params.to_json, admin_headers + expect(last_response).to have_status_code(422) + expect(last_response).to have_error_message("Port '123' is not available. Try a different port or use a different domain.") + end + end + end + + context 'and the user does not provide a port' do + let(:params) do + { + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + + context 'and randomly selected port is already in use' do + let(:existing_route) { VCAP::CloudController::Route.make(host: '', space: space, domain: domain, port: 123) } + + let(:params) do + { + port: existing_route.port, + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + it 'fails with a helpful error message' do + post '/v3/routes', params.to_json, admin_headers + expect(last_response).to have_status_code(422) + expect(last_response).to have_error_message("Route already exists with port '123' for domain 'my.domain'.") + end + end + end + end + end + + context 'when creating a route in a suspended org' do + before do + org.update(status: VCAP::CloudController::Organization::SUSPENDED) + end + + let(:domain) { VCAP::CloudController::SharedDomain.make } + + let(:params) do + { + host: 'some-host', + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + let(:route_json) do + { + guid: UUID_REGEX, + protocol: domain.protocols[0], + host: 'some-host', + path: '', + port: nil, + url: "some-host.#{domain.name}", + created_at: iso8601, + updated_at: iso8601, + destinations: [], + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + }, + metadata: { + labels: {}, + annotations: {} + }, + links: { + self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, + space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}} }, + destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, + domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } + }, + options: {} + } + end + + describe 'valid routes' do + let(:api_call) { ->(user_headers) { post '/v3/routes', params.to_json, user_headers } } + + let(:expected_codes_and_responses) do + h = Hash.new({ code: 403, errors: CF_NOT_AUTHORIZED }.freeze) + h['admin'] = { + code: 201, + response_object: route_json + } + %w[space_developer space_supporter].each { |r| h[r] = { code: 403, errors: CF_ORG_SUSPENDED } } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + end + + context 'when creating a route in an internal domain' do + let(:domain) { VCAP::CloudController::SharedDomain.make(internal: true) } + + describe 'when creating a route with a wildcard host' do + let(:params) do + { + host: '*', + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + it 'fails with a helpful message' do + post '/v3/routes', params.to_json, admin_header + expect(last_response).to have_status_code(422) + expect(last_response).to have_error_message('Wildcard hosts are not supported for internal domains.') + end + end + + describe 'when creating a route with a path' do + let(:params) do + { + host: 'host', + path: '/apath', + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + it 'fails with a helpful message' do + post '/v3/routes', params.to_json, admin_header + expect(last_response).to have_status_code(422) + expect(last_response).to have_error_message('Paths are not supported for internal domains.') + end + end + + describe 'when creating a route with a host' do + let(:params) do + { + host: 'some-host', + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + let(:route_json) do + { + guid: UUID_REGEX, + protocol: domain.protocols[0], + host: 'some-host', + path: '', + port: nil, + url: "some-host.#{domain.name}", + created_at: iso8601, + updated_at: iso8601, + destinations: [], + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + }, + links: { + self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, + space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}} }, + destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, + domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } + }, + metadata: { + labels: {}, + annotations: {} + }, + options: {} + } + end + + describe 'valid routes' do + let(:api_call) { ->(user_headers) { post '/v3/routes', params.to_json, user_headers } } + + let(:expected_codes_and_responses) do + h = Hash.new( + { code: 403 }.freeze + ) + h['admin'] = { + code: 201, + response_object: route_json + } + h['space_developer'] = { + code: 201, + response_object: route_json + } + h['space_supporter'] = { + code: 201, + response_object: route_json + } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + end + end + + context 'when the domain has an owning org that is different from the space\'s parent org' do + let(:other_org) { VCAP::CloudController::Organization.make } + let(:inaccessible_domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: other_org) } + + let(:params_with_inaccessible_domain) do + { + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: inaccessible_domain.guid } + } + } + } + end + + it 'returns a 422 with a helpful error message' do + post '/v3/routes', params_with_inaccessible_domain.to_json, admin_header + expect(last_response).to have_status_code(422) + expect(last_response).to have_error_message("Invalid domain. Domain '#{inaccessible_domain.name}' is not available in organization '#{org.name}'.") + end + end + + context 'when the host-less route has already been created for this domain' do + let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } + let!(:existing_route) { VCAP::CloudController::Route.make(host: '', space: space, domain: domain) } + + let(:params_for_duplicate_route) do + { + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + it 'returns a 422 with a helpful error message' do + post '/v3/routes', params_for_duplicate_route.to_json, admin_header + expect(last_response).to have_status_code(422) + expect(last_response).to have_error_message("Route already exists for domain '#{domain.name}'.") + end + end + + context 'when there is already a route' do + context 'with the host/domain/path combination' do + let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } + let!(:existing_route) { VCAP::CloudController::Route.make(host: 'my-host', path: '/existing', space: space, domain: domain) } + + let(:params_for_duplicate_route) do + { + host: existing_route.host, + path: existing_route.path, + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + it 'returns a 422 with a helpful error message' do + post '/v3/routes', params_for_duplicate_route.to_json, admin_header + expect(last_response).to have_status_code(422) + expect(last_response).to have_error_message("Route already exists with host '#{existing_route.host}' and path '#{existing_route.path}' for domain '#{domain.name}'.") + end + end + + context 'with the host/domain combination' do + let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } + let!(:existing_route) { VCAP::CloudController::Route.make(host: 'my-host', space: space, domain: domain) } + + let(:params_for_duplicate_route) do + { + host: existing_route.host, + path: existing_route.path, + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + it 'returns a 422 with a helpful error message' do + post '/v3/routes', params_for_duplicate_route.to_json, admin_header + expect(last_response).to have_status_code(422) + expect(last_response).to have_error_message("Route already exists with host '#{existing_route.host}' for domain '#{domain.name}'.") + end + end + end + + context 'when there is already a domain matching the host/domain combination' do + let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } + let!(:existing_domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization, name: "#{params[:host]}.#{domain.name}") } + + let(:params) do + { + host: 'some-host', + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + it 'returns a 422 with a helpful error message' do + post '/v3/routes', params.to_json, admin_header + expect(last_response).to have_status_code(422) + expect(last_response).to have_error_message("Route conflicts with domain '#{existing_domain.name}'.") + end + end + + context 'when using a reserved system hostname with the system domain' do + let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } + + let(:params) do + { + host: 'host', + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + before do + VCAP::CloudController::Config.config.set(:system_domain, domain.name) + VCAP::CloudController::Config.config.set(:system_hostnames, [params[:host]]) + end + + it 'returns a 422 with a helpful error message' do + post '/v3/routes', params.to_json, admin_header + expect(last_response).to have_status_code(422) + expect(last_response).to have_error_message('Route conflicts with a reserved system route.') + end + end + + context 'when using a non-reserved hostname with the system domain' do + let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } + let(:api_call) { ->(user_headers) { post '/v3/routes', params.to_json, user_headers } } + + let(:params) do + { + host: 'host', + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + let(:route_json) do + { + guid: UUID_REGEX, + protocol: domain.protocols[0], + host: params[:host], + path: '', + port: nil, + url: "#{params[:host]}.#{domain.name}", + created_at: iso8601, + updated_at: iso8601, + destinations: [], + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + }, + metadata: { + labels: {}, + annotations: {} + }, + links: { + self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, + space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}} }, + destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, + domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } + }, + options: {} + } + end + + let(:expected_codes_and_responses) do + h = Hash.new( + { code: 403 }.freeze + ) + h['admin'] = { + code: 201, + response_object: route_json + } + h['space_developer'] = { + code: 201, + response_object: route_json + } + h['space_supporter'] = { + code: 201, + response_object: route_json + } + h + end + + before do + VCAP::CloudController::Config.config.set(:system_domain, domain.name) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + + describe 'quotas' do + context 'when the space quota for routes is maxed out' do + let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } + let!(:space_quota_definition) { VCAP::CloudController::SpaceQuotaDefinition.make(total_routes: 0, organization: org) } + let!(:space_with_quota) { VCAP::CloudController::Space.make(space_quota_definition: space_quota_definition, organization: org) } + + let(:params_for_space_with_quota) do + { + relationships: { + space: { + data: { guid: space_with_quota.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + it 'returns a 422 with a helpful error message' do + post '/v3/routes', params_for_space_with_quota.to_json, admin_header + expect(last_response).to have_status_code(422) + expect(last_response).to have_error_message("Routes quota exceeded for space '#{space_with_quota.name}'.") + end + end + + context 'when the org quota for routes is maxed out' do + let!(:org_quota_definition) { VCAP::CloudController::QuotaDefinition.make(total_routes: 0, total_reserved_route_ports: 0) } + let!(:org_with_quota) { VCAP::CloudController::Organization.make(quota_definition: org_quota_definition) } + let!(:space_in_org_with_quota) do + VCAP::CloudController::Space.make(organization: org_with_quota) + end + let(:domain_in_org_with_quota) { VCAP::CloudController::Domain.make(owning_organization: org_with_quota) } + + let(:params_for_org_with_quota) do + { + relationships: { + space: { + data: { guid: space_in_org_with_quota.guid } + }, + domain: { + data: { guid: domain_in_org_with_quota.guid } + } + } + } + end + + it 'returns a 422 with a helpful error message' do + post '/v3/routes', params_for_org_with_quota.to_json, admin_header + expect(last_response).to have_status_code(422) + expect(last_response).to have_error_message("Routes quota exceeded for organization '#{org_with_quota.name}'.") + end + end + end + + context 'when the feature flag is disabled' do + let(:headers) { set_user_with_header_as_role(user: user, role: 'space_developer', org: org, space: space) } + let!(:feature_flag) { VCAP::CloudController::FeatureFlag.make(name: 'route_creation', enabled: false, error_message: 'my name is bob') } + let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } + let(:params) do + { + host: 'some-host', + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + context 'when the user is not an admin' do + it 'returns a 403' do + post '/v3/routes', params.to_json, headers + + expect(last_response).to have_status_code(403) + expect(parsed_response['errors'][0]['detail']).to eq('Feature Disabled: my name is bob') + end + end + + context 'when the user is an admin' do + let(:headers) { set_user_with_header_as_role(role: 'admin') } + + it 'allows creation' do + post '/v3/routes', params.to_json, headers + + expect(last_response).to have_status_code(201) + end + end + end + + context 'when the user is not logged in' do + it 'returns 401 for Unauthenticated requests' do + post '/v3/routes', {}.to_json, base_json_headers + expect(last_response).to have_status_code(401) + end + end + + context 'when the user does not have the required scopes' do + let(:user_header) { headers_for(user, scopes: ['cloud_controller.read']) } + + it 'returns a 403' do + post '/v3/routes', {}.to_json, user_header + expect(last_response).to have_status_code(403) + end + end + + context 'when the space does not exist' do + let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } + + let(:params_with_invalid_space) do + { + relationships: { + space: { + data: { guid: 'invalid-space' } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + it 'returns a 422 with a helpful error message' do + post '/v3/routes', params_with_invalid_space.to_json, admin_header + expect(last_response).to have_status_code(422) + expect(last_response).to have_error_message('Invalid space. Ensure that the space exists and you have access to it.') + end + end + + context 'when the domain does not exist' do + let(:params_with_invalid_domain) do + { + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: 'invalid-domain' } + } + } + } + end + + it 'returns a 422 with a helpful error message' do + post '/v3/routes', params_with_invalid_domain.to_json, admin_header + expect(last_response).to have_status_code(422) + expect(last_response).to have_error_message('Invalid domain. Ensure that the domain exists and you have access to it.') + end + end + + context 'when communicating with the routing API' do + let(:routing_api_client) { instance_double(VCAP::CloudController::RoutingApi::Client) } + let(:router_group) { VCAP::CloudController::RoutingApi::RouterGroup.new({ 'type' => 'tcp', 'guid' => 'some-guid' }) } + let(:headers) { set_user_with_header_as_role(role: 'admin') } + let(:domain_tcp) { VCAP::CloudController::SharedDomain.make(router_group_guid: router_group.guid, name: 'my.domain') } + let(:params) do + { + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain_tcp.guid } + } + } + } + end + + before do + allow_any_instance_of(CloudController::DependencyLocator).to receive(:routing_api_client).and_return(routing_api_client) + end + + context 'when UAA is unavailable' do + before do + allow(routing_api_client).to receive(:router_group).and_raise VCAP::CloudController::RoutingApi::UaaUnavailable + end + + it 'returns a 503 with a helpful error message' do + post '/v3/routes', params.to_json, headers + + expect(last_response).to have_status_code(503) + expect(parsed_response['errors'][0]['detail']).to eq 'Communicating with the Routing API failed because UAA is currently unavailable. Please try again later.' + end + end + + context 'when the routing API is unavailable' do + before do + allow(routing_api_client).to receive(:enabled?).and_return true + allow(routing_api_client).to receive(:router_group).and_raise VCAP::CloudController::RoutingApi::RoutingApiUnavailable + end + + it 'returns a 503 with a helpful error message' do + post '/v3/routes', params.to_json, headers + + expect(last_response).to have_status_code(503) + expect(parsed_response['errors'][0]['detail']).to eq 'The Routing API is currently unavailable. Please try again later.' + end + end + + context 'when the routing API is disabled' do + before do + allow(routing_api_client).to receive(:enabled?).and_return false + allow(routing_api_client).to receive(:router_group).and_raise VCAP::CloudController::RoutingApi::RoutingApiDisabled + end + + it 'returns a 503 with a helpful error message' do + post '/v3/routes', params.to_json, headers + + expect(last_response).to have_status_code(503) + expect(parsed_response['errors'][0]['detail']).to eq 'The Routing API is disabled.' + end + end + + context 'when the router group is unavailable' do + let(:domain_tcp) { VCAP::CloudController::SharedDomain.make(router_group_guid: 'not a guid', name: 'my.domain') } + + before do + allow(routing_api_client).to receive_messages(enabled?: true, router_group: nil) + end + + it 'returns a 503 with a helpful error message' do + post '/v3/routes', params.to_json, headers + + expect(last_response.status).to eq(422) + expect(parsed_response['errors'][0]['detail']).to eq 'Route could not be created because the specified domain does not have a valid router group.' + end + end + end + end + + describe 'PATCH /v3/routes/:guid' do + let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } + let(:route) { VCAP::CloudController::Route.make(space: space, domain: domain, host: '') } + let(:api_call) { ->(user_headers) { patch "/v3/routes/#{route.guid}", params.to_json, user_headers } } + let(:params) do + { + metadata: { + labels: { + potato: 'fingerling', + style: 'roasted' + }, + annotations: { + potato: 'russet', + style: 'fried' + } + } + } + end + + let(:route_json) do + { + guid: UUID_REGEX, + protocol: domain.protocols[0], + host: '', + path: '', + port: nil, + url: domain.name, + created_at: iso8601, + updated_at: iso8601, + destinations: [], + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + }, + links: { + self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, + space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}} }, + destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, + domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } + }, + metadata: { + labels: { + potato: 'fingerling', + style: 'roasted' + }, + annotations: { + potato: 'russet', + style: 'fried' + } + }, + options: {} + } + end + + context 'when the user logged in' do + let(:expected_codes_and_responses) do + h = Hash.new({ code: 403, errors: CF_NOT_AUTHORIZED }.freeze) + h['admin'] = { code: 200, response_object: route_json } + h['no_role'] = { code: 404 } + h['org_billing_manager'] = { code: 404 } + h['space_developer'] = { code: 200, response_object: route_json } + h['space_supporter'] = { code: 200, response_object: route_json } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + + context 'when organization is suspended' do + let(:expected_codes_and_responses) do + h = super() + %w[space_developer space_supporter].each { |r| h[r] = { code: 403, errors: CF_ORG_SUSPENDED } } + h + end + + before do + org.update(status: VCAP::CloudController::Organization::SUSPENDED) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + end + + context 'when the user is not a member in the routes org' do + let(:other_space) { VCAP::CloudController::Space.make } + let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: other_space.organization) } + let(:route) { VCAP::CloudController::Route.make(space: other_space, domain: domain, host: '') } + + let(:route_json) do + { + guid: UUID_REGEX, + protocol: domain.protocols[0], + host: '', + path: '', + port: nil, + url: domain.name, + created_at: iso8601, + updated_at: iso8601, + destinations: [], + relationships: { + space: { + data: { guid: other_space.guid } + }, + domain: { + data: { guid: domain.guid } + } + }, + links: { + self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, + space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{other_space.guid}} }, + destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, + domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } + }, + metadata: { + labels: { + potato: 'fingerling', + style: 'roasted' + }, + annotations: { + potato: 'russet', + style: 'fried' + } + }, + options: {} + } + end + let(:expected_codes_and_responses) do + h = Hash.new({ code: 404 }.freeze) + h['admin'] = { + code: 200, + response_object: route_json + } + h['admin_read_only'] = { + code: 403 + } + h['global_auditor'] = { + code: 403 + } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + + context 'when route does not exist' do + it 'returns a 404 with a helpful error message' do + patch "/v3/routes/#{user.guid}", params.to_json, admin_header + + expect(last_response).to have_status_code(404) + expect(last_response).to have_error_message('Route not found') + end + end + + context 'when request input message is invalid' do + let(:params_with_invalid_input) do + { + disallowed_key: 'val' + } + end + + it 'returns a 422' do + patch "/v3/routes/#{route.guid}", params_with_invalid_input.to_json, admin_header + + expect(last_response).to have_status_code(422) + end + end + + context 'when metadata is given with invalid format' do + let(:params_with_invalid_metadata_format) do + { + metadata: { + labels: { + "": 'mashed', + '/potato': '.value.' + } + } + } + end + + it 'returns a 422' do + patch "/v3/routes/#{route.guid}", params_with_invalid_metadata_format.to_json, admin_header + + expect(last_response).to have_status_code(422) + expect(parsed_response['errors'][0]['detail']).to match(/Metadata [\w\s]+ error/) + end + end + + context 'when the user is not logged in' do + it 'returns 401 for Unauthenticated requests' do + patch "/v3/routes/#{route.guid}", nil, base_json_headers + expect(last_response).to have_status_code(401) + end + end + end + + describe 'DELETE /v3/routes/:guid' do + let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } + let(:route) { VCAP::CloudController::Route.make(space:, domain:) } + let(:api_call) { ->(user_headers) { delete "/v3/routes/#{route.guid}", nil, user_headers } } + let(:db_check) do + lambda do + expect(last_response.headers['Location']).to match(%r{http.+/v3/jobs/[a-fA-F0-9-]+}) + + execute_all_jobs(expected_successes: 1, expected_failures: 0) + get "/v3/routes/#{route.guid}", {}, admin_headers + expect(last_response).to have_status_code(404) + end + end + + context 'deleting metadata' do + it_behaves_like 'resource with metadata' do + let(:resource) { route } + let(:api_call) do + -> { delete "/v3/routes/#{route.guid}", nil, admin_header } + end + end + end + + context 'when the user is a member in the routes org' do + let(:expected_codes_and_responses) do + h = Hash.new({ code: 403, errors: CF_NOT_AUTHORIZED }.freeze) + h['org_billing_manager'] = { code: 404 } + h['no_role'] = { code: 404 } + h['admin'] = { code: 202 } + h['space_developer'] = { code: 202 } + h['space_supporter'] = { code: 202 } + h + end + + it_behaves_like 'permissions for delete endpoint', ALL_PERMISSIONS do + let(:expected_event_hash) do + { + type: 'audit.route.delete-request', + actee: route.guid, + actee_type: 'route', + actee_name: route.host, + metadata: { request: { recursive: true } }.to_json, + space_guid: space.guid, + organization_guid: org.guid + } + end + end + + context 'when organization is suspended' do + let(:expected_codes_and_responses) do + h = super() + %w[space_developer space_supporter].each { |r| h[r] = { code: 403, errors: CF_ORG_SUSPENDED } } + h + end + + before do + org.update(status: VCAP::CloudController::Organization::SUSPENDED) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + end + + describe 'when the user is not logged in' do + it 'returns 401 for Unauthenticated requests' do + delete "/v3/routes/#{route.guid}", nil, base_json_headers + expect(last_response).to have_status_code(401) + end + end + end + + describe 'GET /v3/routes/:guid/relationships/shared_spaces' do + let(:api_call) { ->(user_headers) { get "/v3/routes/#{guid}/relationships/shared_spaces", nil, user_headers } } + let(:target_space_1) { VCAP::CloudController::Space.make(organization: org) } + let(:route) do + route = VCAP::CloudController::Route.make(space:) + route.add_shared_space(target_space_1) + route + end + let(:guid) { route.guid } + let(:space_dev_headers) do + org.add_user(user) + space.add_developer(user) + headers_for(user) + end + let!(:feature_flag) do + VCAP::CloudController::FeatureFlag.make(name: 'route_sharing', enabled: true, error_message: nil) + end + + before do + org.add_user(user) + target_space_1.add_developer(user) + end + + describe 'permissions' do + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS do + let(:expected_codes_and_responses) do + h = Hash.new({ code: 200, response_object: { + data: [ + { + guid: target_space_1.guid + } + ], + links: { + self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{route.guid}/relationships/shared_spaces} } + } + } }.freeze) + + h['org_billing_manager'] = { code: 404 } + h['no_role'] = { code: 404 } + h + end + end + end + + describe 'when route_sharing flag is disabled' do + before do + feature_flag.enabled = false + feature_flag.save + end + + it 'makes users unable to unshare routes' do + api_call.call(space_dev_headers) + + expect(last_response).to have_status_code(403) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => 'Feature Disabled: route_sharing', + 'title' => 'CF-FeatureDisabled', + 'code' => 330_002 + } + ) + ) + end + end + + it 'responds with 404 when the route does not exist' do + get '/v3/routes/some-fake-guid/relationships/shared_spaces', nil, space_dev_headers + + expect(last_response).to have_status_code(404) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => 'Route not found', + 'title' => 'CF-ResourceNotFound' + } + ) + ) + end + end + + describe 'POST /v3/routes/:guid/relationships/shared_spaces' do + let(:api_call) { ->(user_headers) { post "/v3/routes/#{guid}/relationships/shared_spaces", request_body.to_json, user_headers } } + let(:target_space_1) { VCAP::CloudController::Space.make(organization: org) } + let(:target_space_2) { VCAP::CloudController::Space.make(organization: org) } + let(:request_body) do + { + 'data' => [ + { 'guid' => target_space_1.guid }, + { 'guid' => target_space_2.guid } + ] + } + end + let(:route) { VCAP::CloudController::Route.make(space:) } + let(:guid) { route.guid } + let(:space_dev_headers) do + org.add_user(user) + space.add_developer(user) + headers_for(user) + end + let!(:feature_flag) do + VCAP::CloudController::FeatureFlag.make(name: 'route_sharing', enabled: true, error_message: nil) + end + + before do + org.add_user(user) + target_space_1.add_developer(user) + target_space_2.add_developer(user) + end + + describe 'permissions' do + let(:expected_codes_and_responses) do + h = Hash.new({ code: 403, errors: CF_NOT_AUTHORIZED }.freeze) + + h['org_billing_manager'] = { code: 404 } + h['no_role'] = { code: 404 } + + h['admin'] = { code: 200 } + h['space_developer'] = { code: 200 } + h['space_supporter'] = { code: 200 } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + + context 'when organization is suspended' do + let(:expected_codes_and_responses) do + h = super() + %w[space_developer space_supporter].each { |r| h[r] = { code: 403, errors: CF_ORG_SUSPENDED } } + h + end + + before do + org.update(status: VCAP::CloudController::Organization::SUSPENDED) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + + context 'when target organization is suspended' do + let(:target_space_1) do + space = VCAP::CloudController::Space.make + space.organization.add_user(user) + space.organization.update(status: VCAP::CloudController::Organization::SUSPENDED) + space + end + + let(:expected_codes_and_responses) do + h = super() + %w[space_developer space_supporter].each { |r| h[r] = { code: 422 } } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + end + + it 'shares the route to the target space and logs audit event' do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(200) + + event = VCAP::CloudController::Event.last + expect(event.values).to include({ + type: 'audit.route.share', + actor: user.guid, + actee_type: 'route', + actee_name: route.host, + space_guid: space.guid, + organization_guid: space.organization.guid + }) + expect(event.metadata['target_space_guids']).to include(target_space_1.guid, target_space_2.guid) + + route.reload + expect(route.shared_spaces).to include(target_space_1, target_space_2) + end + + it 'reports that the route is now shared' do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(200) + route.reload + expect(route.shared_spaces).to include(target_space_1, target_space_2) + expect(route).to be_shared + end + + it 'reports that the route is not shared when it has not been shared' do + route.reload + expect(route.shared_spaces).to be_empty + expect(route).not_to be_shared + end + + describe 'when route_sharing flag is disabled' do + before do + feature_flag.enabled = false + feature_flag.save + end + + it 'makes users unable to share routes' do + api_call.call(space_dev_headers) + + expect(last_response).to have_status_code(403) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => 'Feature Disabled: route_sharing', + 'title' => 'CF-FeatureDisabled', + 'code' => 330_002 + } + ) + ) + end + end + + it 'responds with 404 when the route does not exist' do + post '/v3/routes/some-fake-guid/relationships/shared_spaces', request_body.to_json, space_dev_headers + + expect(last_response).to have_status_code(404) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => 'Route not found', + 'title' => 'CF-ResourceNotFound' + } + ) + ) + end + + describe 'when the request body is invalid' do + context 'when it is not a valid relationship' do + let(:request_body) do + { + 'data' => { 'guid' => target_space_1.guid } + } + end + + it 'responds with 422' do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(422) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => 'Data must be an array', + 'title' => 'CF-UnprocessableEntity' + } + ) + ) + end + end + + context 'when there are additional keys' do + let(:request_body) do + { + 'data' => [ + { 'guid' => target_space_1.guid } + ], + 'fake-key' => 'foo' + } + end + + it 'responds with 422' do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(422) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => "Unknown field(s): 'fake-key'", + 'title' => 'CF-UnprocessableEntity' + } + ) + ) + end + end + end + + describe 'target space to share to' do + context 'does not exist' do + let(:target_space_guid) { 'fake-target' } + let(:request_body) do + { + 'data' => [ + { 'guid' => target_space_guid } + ] + } + end + + it 'responds with 422' do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(422) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => "Unable to share route #{route.uri} with spaces ['#{target_space_guid}']. " \ + 'Ensure the spaces exist and that you have access to them.', + 'title' => 'CF-UnprocessableEntity' + } + ) + ) + end + end + + context 'user does not have access to one of the target spaces' do + let(:no_access_target_space) { VCAP::CloudController::Space.make(organization: org) } + let(:request_body) do + { + 'data' => [ + { 'guid' => no_access_target_space.guid }, + { 'guid' => target_space_1.guid } + ] + } + end + + it 'responds with 422 and does not share the route' do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(422) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => "Unable to share route #{route.uri} with spaces ['#{no_access_target_space.guid}']. " \ + 'Ensure the spaces exist and that you have access to them.', + 'title' => 'CF-UnprocessableEntity' + } + ) + ) + + route.reload + expect(route).not_to be_shared + end + end + + context 'already owns the route' do + let(:request_body) do + { + 'data' => [ + { 'guid' => space.guid }, + { 'guid' => target_space_1.guid } + ] + } + end + + it 'responds with 422 and does not share the route' do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(422) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => "Unable to share route '#{route.uri}' with space '#{space.guid}'. " \ + 'Routes cannot be shared into the space where they were created.', + 'title' => 'CF-UnprocessableEntity' + } + ) + ) + + route.reload + expect(route).not_to be_shared + end + end + end + + describe 'errors while sharing' do + # isolation segments? + end + end + + describe 'DELETE /v3/routes/:guid/relationships/shared_spaces/:space_guid' do + let(:api_call) { ->(user_headers) { delete "/v3/routes/#{guid}/relationships/shared_spaces/#{unshared_space_guid}", request_body.to_json, user_headers } } + let(:target_space_1) { VCAP::CloudController::Space.make(organization: org) } + let(:target_space_2) { VCAP::CloudController::Space.make(organization: org) } + let(:target_space_3) { VCAP::CloudController::Space.make(organization: org) } + let(:target_space_not_shared_with_route) { VCAP::CloudController::Space.make(organization: org) } + let(:space_to_unshare) { target_space_2 } + let(:unshared_space_guid) { space_to_unshare.guid } + let(:request_body) { {} } + let(:route) do + route = VCAP::CloudController::Route.make(space:) + route.add_shared_space(target_space_1) + route.add_shared_space(target_space_2) + route.add_shared_space(target_space_3) + route + end + let(:guid) { route.guid } + let(:space_dev_headers) do + org.add_user(user) + space.add_developer(user) + headers_for(user) + end + let!(:feature_flag) do + VCAP::CloudController::FeatureFlag.make(name: 'route_sharing', enabled: true, error_message: nil) + end + + before do + org.add_user(user) + target_space_1.add_developer(user) + target_space_2.add_developer(user) + target_space_not_shared_with_route.add_developer(user) + end + + describe 'permissions' do + let(:expected_codes_and_responses) do + h = Hash.new({ code: 403, errors: CF_NOT_AUTHORIZED }.freeze) + + h['org_billing_manager'] = { code: 404 } + h['no_role'] = { code: 404 } + + h['admin'] = { code: 204 } + h['space_developer'] = { code: 204 } + h['space_supporter'] = { code: 204 } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + + context 'when organization is suspended' do + let(:expected_codes_and_responses) do + h = super() + %w[space_developer space_supporter].each { |r| h[r] = { code: 403, errors: CF_ORG_SUSPENDED } } + h + end + + before do + org.update(status: VCAP::CloudController::Organization::SUSPENDED) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + + context 'when target organization is suspended' do + let(:space_to_unshare) do + space = VCAP::CloudController::Space.make + space.organization.add_user(user) + space.add_developer(user) + space.organization.update(status: VCAP::CloudController::Organization::SUSPENDED) + space + end + + let(:expected_codes_and_responses) do + h = super() + %w[space_developer space_supporter].each do |r| + h[r] = { + code: 422, + errors: [{ + detail: "Unable to unshare route '#{route.uri}' from space '#{space_to_unshare.guid}'. The target organization is suspended.", + title: 'CF-UnprocessableEntity', + code: 10_008 + }] + } + end + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + end + + it 'unshares the specified route from the target space and logs audit event' do + expect(route.shared_spaces).to include(target_space_1, space_to_unshare, target_space_3) + + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(204) + + event = VCAP::CloudController::Event.last + expect(event.values).to include({ + type: 'audit.route.unshare', + actor: user.guid, + actee_type: 'route', + actee_name: route.host, + space_guid: space.guid, + organization_guid: space.organization.guid + }) + expect(event.metadata['target_space_guid']).to eq(unshared_space_guid) + + route.reload + expect(route.shared_spaces).to include(target_space_1, target_space_3) + end + + describe 'when route_sharing flag is disabled' do + before do + feature_flag.enabled = false + feature_flag.save + end + + it 'makes users unable to unshare routes' do + api_call.call(space_dev_headers) + + expect(last_response).to have_status_code(403) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => 'Feature Disabled: route_sharing', + 'title' => 'CF-FeatureDisabled', + 'code' => 330_002 + } + ) + ) + end + end + + it 'responds with 204 when the route is not shared with the specified space' do + delete "/v3/routes/#{route.guid}/relationships/shared_spaces/#{target_space_not_shared_with_route.guid}", request_body.to_json, space_dev_headers + + expect(last_response.status).to eq(204) + end + + it "responds with 404 when the route doesn't exist" do + delete "/v3/routes/some-fake-guid/relationships/shared_spaces/#{target_space_1.guid}", request_body.to_json, space_dev_headers + + expect(last_response).to have_status_code(404) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => 'Route not found', + 'title' => 'CF-ResourceNotFound' + } + ) + ) + end + + context 'attempting to unshare from space that owns us' do + let(:space_to_unshare) { space } + + it 'responds with 422 and does not unshare the roue' do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(422) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => "Unable to unshare route '#{route.uri}' from space " \ + "'#{space.guid}'. Routes cannot be removed from the space that owns them.", + 'title' => 'CF-UnprocessableEntity' + } + ) + ) + + route.reload + expect(route.shared_spaces).to contain_exactly(target_space_1, target_space_2, target_space_3) + end + end + + describe 'target space to unshare with' do + context 'does not exist' do + let(:unshared_space_guid) { 'fake-target' } + + it 'responds with 422' do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(422) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => "Unable to unshare route '#{route.uri}' from space '#{unshared_space_guid}'. " \ + 'Ensure the space exists and that you have access to it.', + 'title' => 'CF-UnprocessableEntity' + } + ) + ) + end + end + + context 'user does not have read access to the target space' do + let(:unshared_space_guid) { VCAP::CloudController::Space.make(organization: org).guid } + + it 'responds with 422 and does not share the route' do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(422) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => "Unable to unshare route '#{route.uri}' from space '#{unshared_space_guid}'. " \ + 'Ensure the space exists and that you have access to it.', + 'title' => 'CF-UnprocessableEntity' + } + ) + ) + end + end + + context 'user does not have write access to the target space' do + let(:no_write_access_target_space) { VCAP::CloudController::Space.make(organization: org) } + let(:unshared_space_guid) { no_write_access_target_space.guid } + + before do + no_write_access_target_space.add_auditor(user) + end + + it 'responds with 422 and does not share the route' do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(422) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => "Unable to unshare route '#{route.uri}' from space '#{no_write_access_target_space.guid}'. " \ + "You don't have write permission for the target space.", + 'title' => 'CF-UnprocessableEntity' + } + ) + ) + end + end + end + end + + describe 'PATCH /v3/routes/:guid/relationships/space' do + let(:shared_domain) { VCAP::CloudController::SharedDomain.make } + let(:route) { VCAP::CloudController::Route.make(space: space, domain: shared_domain) } + let(:api_call) { ->(user_headers) { patch "/v3/routes/#{route.guid}/relationships/space", request_body.to_json, user_headers } } + let(:target_space) { VCAP::CloudController::Space.make(organization: org) } + let(:request_body) do + { + data: { 'guid' => target_space.guid } + } + end + let(:space_dev_headers) do + org.add_user(user) + space.add_developer(user) + headers_for(user) + end + let!(:feature_flag) do + VCAP::CloudController::FeatureFlag.make(name: 'route_sharing', enabled: true, error_message: nil) + end + + before do + org.add_user(user) + target_space.add_developer(user) + end + + context 'when the user logged in' do + let(:expected_codes_and_responses) do + h = Hash.new({ code: 403, errors: CF_NOT_AUTHORIZED }.freeze) + h['admin'] = { code: 200 } + h['no_role'] = { code: 404 } + h['org_billing_manager'] = { code: 404 } + h['space_developer'] = { code: 200 } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + + context 'when organization is suspended' do + let(:expected_codes_and_responses) do + h = super() + %w[space_developer].each { |r| h[r] = { code: 403, errors: CF_ORG_SUSPENDED } } + h + end + + before do + org.update(status: VCAP::CloudController::Organization::SUSPENDED) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + + context 'when target organization is suspended' do + let(:suspended_space) { VCAP::CloudController::Space.make } + let(:request_body) do + { + data: { 'guid' => suspended_space.guid } + } + end + + let(:expected_codes_and_responses) do + h = super() + %w[space_developer].each do |r| + h[r] = { + code: 422, + errors: [{ + detail: "Unable to transfer owner of route '#{route.uri}' to space '#{suspended_space.guid}'. The target organization is suspended.", + title: 'CF-UnprocessableEntity', + code: 10_008 + }] + } + end + h + end + + before do + suspended_space.organization.add_user(user) + suspended_space.add_developer(user) + suspended_space.organization.update(status: VCAP::CloudController::Organization::SUSPENDED) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + end + + it 'changes the route owner to the given space and logs an event', isolation: :truncation do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(200) + + event = VCAP::CloudController::Event.last + expect(event.values).to include({ + type: 'audit.route.transfer-owner', + actor: user.guid, + actee_type: 'route', + actee_name: route.host, + space_guid: space.guid, + organization_guid: space.organization.guid + }) + expect(event.metadata['target_space_guid']).to eq(target_space.guid) + + route.reload + expect(route.space).to eq target_space + end + + describe 'when using a private domain' do + let(:private_domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: org) } + let(:route) { VCAP::CloudController::Route.make(space: space, domain: private_domain) } + let(:second_org) { VCAP::CloudController::Organization.make } + let(:another_space) { VCAP::CloudController::Space.make(organization: second_org) } + let(:request_body) do + { + data: { 'guid' => another_space.guid } + } + end + let(:space_dev_headers) do + org.add_user(user) + space.add_developer(user) + second_org.add_user(user) + another_space.add_developer(user) + headers_for(user) + end + + it 'responds with 422' do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(422) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => "Unable to transfer owner of route '#{route.uri}' to space '#{another_space.guid}'. " \ + "Target space does not have access to route's domain", + 'title' => 'CF-UnprocessableEntity' + } + ) + ) + end + end + + describe 'target space to transfer to' do + context 'does not exist' do + let(:target_space_guid) { 'fake-target' } + let(:request_body) do + { + data: { 'guid' => target_space_guid } + } + end + + it 'responds with 422' do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(422) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => "Unable to transfer owner of route '#{route.uri}' to space '#{target_space_guid}'. " \ + 'Ensure the space exists and that you have access to it.', + 'title' => 'CF-UnprocessableEntity' + } + ) + ) + end + end + + context 'user does not have read access to the target space' do + let(:no_access_target_space) { VCAP::CloudController::Space.make(organization: org) } + let(:request_body) do + { + data: { 'guid' => no_access_target_space.guid } + } + end + + it 'responds with 422 and does not share the route' do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(422) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => "Unable to transfer owner of route '#{route.uri}' to space '#{no_access_target_space.guid}'. " \ + 'Ensure the space exists and that you have access to it.', + 'title' => 'CF-UnprocessableEntity' + } + ) + ) + end + end + + context 'user does not have write access to the target space' do + let(:no_write_access_target_space) { VCAP::CloudController::Space.make(organization: org) } + let(:request_body) do + { + data: { 'guid' => no_write_access_target_space.guid } + } + end + + before do + no_write_access_target_space.add_auditor(user) + end + + it 'responds with 422 and does not share the route' do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(422) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => "Unable to transfer owner of route '#{route.uri}' to space '#{no_write_access_target_space.guid}'. " \ + "You don't have write permission for the target space.", + 'title' => 'CF-UnprocessableEntity' + } + ) + ) + end + end + end + + it 'responds with 404 when the route does not exist' do + patch '/v3/routes/some-fake-guid/relationships/space', request_body.to_json, space_dev_headers + + expect(last_response).to have_status_code(404) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => 'Route not found', + 'title' => 'CF-ResourceNotFound' + } + ) + ) + end + + describe 'when the request body is invalid' do + context 'when there are additional keys' do + let(:request_body) do + { + data: { 'guid' => target_space.guid }, + 'fake-key' => 'foo' + } + end + + it 'responds with 422' do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(422) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => "Unknown field(s): 'fake-key'", + 'title' => 'CF-UnprocessableEntity' + } + ) + ) + end + end + + context 'when data is not a hash' do + let(:request_body) do + { + data: [{ 'guid' => target_space.guid }] + } + end + + it 'responds with 422' do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(422) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => 'Data must be an object', + 'title' => 'CF-UnprocessableEntity' + } + ) + ) + end + end + end + + describe 'when route_sharing flag is disabled' do + before do + feature_flag.enabled = false + feature_flag.save + end + + it 'makes users unable to transfer-owner' do + api_call.call(space_dev_headers) + + expect(last_response).to have_status_code(403) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => 'Feature Disabled: route_sharing', + 'title' => 'CF-FeatureDisabled', + 'code' => 330_002 + } + ) + ) + end + end + end + + describe 'GET /v3/apps/:app_guid/routes' do + let(:app_model) { VCAP::CloudController::AppModel.make(space:) } + let(:route1) { VCAP::CloudController::Route.make(space:) } + let(:route2) { VCAP::CloudController::Route.make(space:) } + let!(:route3) { VCAP::CloudController::Route.make(space:) } + let!(:route_mapping1) { VCAP::CloudController::RouteMappingModel.make(app: app_model, route: route1, process_type: 'web') } + let!(:route_mapping2) { VCAP::CloudController::RouteMappingModel.make(app: app_model, route: route2, process_type: 'admin') } + let(:api_call) { ->(user_headers) { get "/v3/apps/#{app_model.guid}/routes", nil, user_headers } } + + let(:route1_json) do + { + guid: route1.guid, + protocol: route1.domain.protocols[0], + host: route1.host, + path: route1.path, + port: nil, + url: "#{route1.host}.#{route1.domain.name}#{route1.path}", + created_at: iso8601, + updated_at: iso8601, + destinations: contain_exactly({ + guid: route_mapping1.guid, + app: { + guid: app_model.guid, + process: { + type: route_mapping1.process_type + } + }, + weight: route_mapping1.weight, + port: route_mapping1.presented_port, + protocol: 'http1', + created_at: iso8601, + updated_at: iso8601 + }), + relationships: { + space: { + data: { guid: route1.space.guid } + }, + domain: { + data: { guid: route1.domain.guid } + } + }, + metadata: { + labels: {}, + annotations: {} + }, + links: { + self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{route1.guid}} }, + space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{route1.space.guid}} }, + destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{route1.guid}/destinations} }, + domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{route1.domain.guid}} } + }, + options: {} + } + end + + let(:route2_json) do + { + guid: route2.guid, + protocol: route2.domain.protocols[0], + host: route2.host, + path: route2.path, + port: nil, + url: "#{route2.host}.#{route2.domain.name}#{route2.path}", + created_at: iso8601, + updated_at: iso8601, + destinations: contain_exactly({ + guid: route_mapping2.guid, + app: { + guid: app_model.guid, + process: { + type: route_mapping2.process_type + } + }, + weight: route_mapping2.weight, + port: route_mapping2.presented_port, + protocol: 'http1', + created_at: iso8601, + updated_at: iso8601 + }), + relationships: { + space: { + data: { guid: route2.space.guid } + }, + domain: { + data: { guid: route2.domain.guid } + } + }, + metadata: { + labels: {}, + annotations: {} + }, + links: { + self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{route2.guid}} }, + space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{route2.space.guid}} }, + destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{route2.guid}/destinations} }, + domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{route2.domain.guid}} } + }, + options: {} + } + end + + context 'when the user is a member in the app space' do + let(:expected_codes_and_responses) do + h = Hash.new( + { code: 200, + response_objects: [route1_json, route2_json] }.freeze + ) + + h['org_auditor'] = { code: 404 } + h['org_billing_manager'] = { code: 404 } + h['no_role'] = { code: 404 } + h + end + + it_behaves_like 'permissions for list endpoint', ALL_PERMISSIONS + end + + context 'ports filter' do + # Don't even think of converting the following hash to symbols ('type' => 'tcp' NOT type: 'tcp'), and you need to set the GUID + let(:router_group) { VCAP::CloudController::RoutingApi::RouterGroup.new({ 'type' => 'tcp', 'reservable_ports' => '7777,8888,9999', 'guid' => 'some-guid' }) } + let(:routing_api_client) { instance_double(VCAP::CloudController::RoutingApi::Client) } + + before do + allow_any_instance_of(CloudController::DependencyLocator).to receive(:routing_api_client).and_return(routing_api_client) + allow(routing_api_client).to receive_messages(enabled?: true, router_group: router_group) + end + + context 'when there are multiple TCP routes with different ports' do + # The following `let`s depend on the above `before do` + let(:domain_tcp) { VCAP::CloudController::SharedDomain.make(router_group_guid: router_group.guid, name: 'my.domain') } + let!(:route_with_ports_0) do + VCAP::CloudController::Route.make(host: '', space: space, domain: domain_tcp, guid: 'route-with-port-0', port: 7777) + end + let!(:route_with_ports_1) do + VCAP::CloudController::Route.make(host: '', space: space, domain: domain_tcp, guid: 'route-with-port-1', port: 8888) + end + let!(:route_with_ports_2) do + VCAP::CloudController::Route.make(host: '', space: space, domain: domain_tcp, guid: 'route-with-port-2', port: 9999) + end + let!(:route_mapping_1) { VCAP::CloudController::RouteMappingModel.make(app: app_model, route: route_with_ports_1, process_type: 'web') } + let!(:route_mapping_2) { VCAP::CloudController::RouteMappingModel.make(app: app_model, route: route_with_ports_2, process_type: 'web') } + + it 'returns routes filtered by ports' do + get "/v3/apps/#{app_model.guid}/routes?ports=7777,8888", nil, admin_header + expect(last_response).to have_status_code(200) + expect(parsed_response['resources'].size).to eq(1) + expect(parsed_response['resources'].first['port']).to eq(route_with_ports_1.port) + end + end + end + + describe 'eager loading' do + it 'eager loads associated resources that the presenter specifies' do + expect(VCAP::CloudController::RouteFetcher).to receive(:fetch).with( + anything, + hash_including(eager_loaded_associations: %i[domain space route_mappings labels annotations]) + ).and_call_original + + get "/v3/apps/#{app_model.guid}/routes", nil, admin_header + expect(last_response).to have_status_code(200) + end + end + end +end From e101b03b9e4cc31fc6e499ac1a47068531f52c10 Mon Sep 17 00:00:00 2001 From: johha Date: Mon, 2 Mar 2026 17:10:24 +0100 Subject: [PATCH 13/20] Convert 14 more message specs to lightweight_spec_helper Add errors_on helper and VCAP::CloudController::Config stub to lightweight_spec_helper to enable more message spec conversions. Converted specs: - deployment_update_message_spec.rb - domain_create_message_spec.rb - droplet_copy_message_spec.rb - droplet_create_message_spec.rb - droplet_update_message_spec.rb - isolation_segment_create_message_spec.rb - isolation_segment_update_message_spec.rb - metadata_base_message_spec.rb - organization_quotas_list_message_spec.rb - service_brokers_list_message_spec.rb - space_quotas_list_message_spec.rb - update_environment_variables_message_spec.rb - users_list_message_spec.rb - validators/url_validator_spec.rb Now 70 message specs use lightweight_spec_helper (vs 82 on spec_helper). --- spec/lightweight_spec_helper.rb | 27 +++++++++++++++++++ .../deployment_update_message_spec.rb | 2 +- .../messages/domain_create_message_spec.rb | 2 +- .../messages/droplet_copy_message_spec.rb | 2 +- .../messages/droplet_create_message_spec.rb | 2 +- .../messages/droplet_update_message_spec.rb | 2 +- .../isolation_segment_create_message_spec.rb | 2 +- .../isolation_segment_update_message_spec.rb | 2 +- .../messages/metadata_base_message_spec.rb | 2 +- .../organization_quotas_list_message_spec.rb | 3 ++- .../service_brokers_list_message_spec.rb | 2 +- .../space_quotas_list_message_spec.rb | 2 +- ...date_environment_variables_message_spec.rb | 2 +- spec/unit/messages/users_list_message_spec.rb | 2 +- .../messages/validators/url_validator_spec.rb | 2 +- 15 files changed, 42 insertions(+), 14 deletions(-) diff --git a/spec/lightweight_spec_helper.rb b/spec/lightweight_spec_helper.rb index 89b08acd137..7a1442eea5c 100644 --- a/spec/lightweight_spec_helper.rb +++ b/spec/lightweight_spec_helper.rb @@ -2,10 +2,21 @@ $LOAD_PATH.push(File.expand_path(File.join(__dir__, '..', 'lib'))) require 'active_support/all' +require 'active_model' require 'pry' # So that specs using this helper don't fail with undefined constant error module VCAP module CloudController + # Minimal Config stub for message validation specs + class Config + def self.config + @config ||= new + end + + def get(*_keys) + nil + end + end end end @@ -34,3 +45,19 @@ def get(key) RSpec.configure do |rspec_config| rspec_config.expose_dsl_globally = false end + +# errors_on helper from rspec-collection_matchers gem +# Enables: expect(message.errors_on(:attribute)).to include("error message") +# This extension is added when ActiveModel::Validations is loaded +if defined?(ActiveModel::Validations) + module ::ActiveModel::Validations + def errors_on(attribute, options={}) + valid_args = [options[:context]].compact + valid?(*valid_args) + + [errors[attribute]].flatten.compact + end + + alias_method :error_on, :errors_on + end +end diff --git a/spec/unit/messages/deployment_update_message_spec.rb b/spec/unit/messages/deployment_update_message_spec.rb index 816d09de210..bdb41ce7c55 100644 --- a/spec/unit/messages/deployment_update_message_spec.rb +++ b/spec/unit/messages/deployment_update_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/deployment_update_message' module VCAP::CloudController diff --git a/spec/unit/messages/domain_create_message_spec.rb b/spec/unit/messages/domain_create_message_spec.rb index f7dae8db280..f83785791ee 100644 --- a/spec/unit/messages/domain_create_message_spec.rb +++ b/spec/unit/messages/domain_create_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/domain_create_message' module VCAP::CloudController diff --git a/spec/unit/messages/droplet_copy_message_spec.rb b/spec/unit/messages/droplet_copy_message_spec.rb index 3432244cdab..928c7f360b6 100644 --- a/spec/unit/messages/droplet_copy_message_spec.rb +++ b/spec/unit/messages/droplet_copy_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/droplet_copy_message' module VCAP::CloudController diff --git a/spec/unit/messages/droplet_create_message_spec.rb b/spec/unit/messages/droplet_create_message_spec.rb index 92140109d81..103c75c1624 100644 --- a/spec/unit/messages/droplet_create_message_spec.rb +++ b/spec/unit/messages/droplet_create_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/droplet_create_message' module VCAP::CloudController diff --git a/spec/unit/messages/droplet_update_message_spec.rb b/spec/unit/messages/droplet_update_message_spec.rb index f26e8d628ef..10ab0c67576 100644 --- a/spec/unit/messages/droplet_update_message_spec.rb +++ b/spec/unit/messages/droplet_update_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/droplet_update_message' module VCAP::CloudController diff --git a/spec/unit/messages/isolation_segment_create_message_spec.rb b/spec/unit/messages/isolation_segment_create_message_spec.rb index 21433d30abd..e1d3ab6d624 100644 --- a/spec/unit/messages/isolation_segment_create_message_spec.rb +++ b/spec/unit/messages/isolation_segment_create_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/isolation_segment_create_message' module VCAP::CloudController diff --git a/spec/unit/messages/isolation_segment_update_message_spec.rb b/spec/unit/messages/isolation_segment_update_message_spec.rb index 78eabff4d1e..6129cf2ccc4 100644 --- a/spec/unit/messages/isolation_segment_update_message_spec.rb +++ b/spec/unit/messages/isolation_segment_update_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/isolation_segment_update_message' module VCAP::CloudController diff --git a/spec/unit/messages/metadata_base_message_spec.rb b/spec/unit/messages/metadata_base_message_spec.rb index 55b4a553958..ea7f73bbc9e 100644 --- a/spec/unit/messages/metadata_base_message_spec.rb +++ b/spec/unit/messages/metadata_base_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/metadata_base_message' module VCAP::CloudController diff --git a/spec/unit/messages/organization_quotas_list_message_spec.rb b/spec/unit/messages/organization_quotas_list_message_spec.rb index 609c5baf255..37be96bd2ea 100644 --- a/spec/unit/messages/organization_quotas_list_message_spec.rb +++ b/spec/unit/messages/organization_quotas_list_message_spec.rb @@ -1,4 +1,5 @@ -require 'spec_helper' +require 'lightweight_spec_helper' +require 'messages/organization_quotas_list_message' module VCAP::CloudController RSpec.describe OrganizationQuotasListMessage do diff --git a/spec/unit/messages/service_brokers_list_message_spec.rb b/spec/unit/messages/service_brokers_list_message_spec.rb index cd6a898ea0d..a01c72162f8 100644 --- a/spec/unit/messages/service_brokers_list_message_spec.rb +++ b/spec/unit/messages/service_brokers_list_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/service_brokers_list_message' module VCAP::CloudController diff --git a/spec/unit/messages/space_quotas_list_message_spec.rb b/spec/unit/messages/space_quotas_list_message_spec.rb index 7d860405fb9..2a5bfc5b526 100644 --- a/spec/unit/messages/space_quotas_list_message_spec.rb +++ b/spec/unit/messages/space_quotas_list_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/space_quotas_list_message' module VCAP::CloudController diff --git a/spec/unit/messages/update_environment_variables_message_spec.rb b/spec/unit/messages/update_environment_variables_message_spec.rb index 3f77983edf1..2453a7e3d9d 100644 --- a/spec/unit/messages/update_environment_variables_message_spec.rb +++ b/spec/unit/messages/update_environment_variables_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/update_environment_variables_message' module VCAP::CloudController diff --git a/spec/unit/messages/users_list_message_spec.rb b/spec/unit/messages/users_list_message_spec.rb index 2774262293d..24b2b5cc8bb 100644 --- a/spec/unit/messages/users_list_message_spec.rb +++ b/spec/unit/messages/users_list_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/users_list_message' module VCAP::CloudController diff --git a/spec/unit/messages/validators/url_validator_spec.rb b/spec/unit/messages/validators/url_validator_spec.rb index c6521ec0f6d..470699cd037 100644 --- a/spec/unit/messages/validators/url_validator_spec.rb +++ b/spec/unit/messages/validators/url_validator_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/validators/url_validator' module VCAP::CloudController::Validators From e85dca1dc0947e2b4b6385f3fd5913b3aefa8eef Mon Sep 17 00:00:00 2001 From: johha Date: Mon, 2 Mar 2026 17:24:39 +0100 Subject: [PATCH 14/20] Fix Config stub to not conflict with spec_helper Only define the minimal Config stub if it's not already defined, to avoid overriding the real Config class when spec_helper is also loaded. --- spec/lightweight_spec_helper.rb | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/spec/lightweight_spec_helper.rb b/spec/lightweight_spec_helper.rb index 7a1442eea5c..14a7ed863ed 100644 --- a/spec/lightweight_spec_helper.rb +++ b/spec/lightweight_spec_helper.rb @@ -8,13 +8,16 @@ module VCAP module CloudController # Minimal Config stub for message validation specs - class Config - def self.config - @config ||= new - end + # Only define if not already defined (avoid conflict with spec_helper) + unless defined?(Config) + class Config + def self.config + @config ||= new + end - def get(*_keys) - nil + def get(*_keys) + nil + end end end end From 9bc28c18524c0e73a1fa8d0e063c75da863a0e4c Mon Sep 17 00:00:00 2001 From: johha Date: Mon, 2 Mar 2026 17:45:24 +0100 Subject: [PATCH 15/20] Optimize spec_helper: skip Fog reset for most tests Move Fog mock bucket setup to before(:suite) and only reset Fog mocks for tests that explicitly need it (tagged with :fog_reset). This optimization saves ~5.3ms per test by avoiding unnecessary bucket recreation. Only ~6% of tests actually use blobstores, so this benefits the majority of tests. Tests that need a clean Fog state can add `:fog_reset` metadata to opt-in to the previous behavior. --- spec/spec_helper.rb | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 8dd57e0bbfe..030aadaa1d7 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -159,6 +159,14 @@ # calling this more than once will load tasks again and 'invoke' or 'execute' calls # will call rake tasks multiple times Application.load_tasks + + # Set up Fog mock buckets once at suite start instead of every test + if Fog.mock? + CloudController::DependencyLocator.instance.droplet_blobstore.ensure_bucket_exists + CloudController::DependencyLocator.instance.package_blobstore.ensure_bucket_exists + CloudController::DependencyLocator.instance.global_app_bits_cache.ensure_bucket_exists + CloudController::DependencyLocator.instance.buildpack_blobstore.ensure_bucket_exists + end end rspec_config.before do @@ -169,21 +177,24 @@ TestConfig.context = example.metadata[:job_context] || :api TestConfig.reset - Fog::Mock.reset + VCAP::CloudController::SecurityContext.clear + VCAP::Request.current_id = nil + allow_any_instance_of(VCAP::CloudController::UaaTokenDecoder).to receive(:uaa_issuer).and_return(UAAIssuer::ISSUER) + mock_redis = MockRedis.new + allow(Redis).to receive(:new).and_return(mock_redis) + end + + # Only reset Fog mocks for tests that use blobstores (tagged with :fog_reset) + # This avoids the overhead of clearing and recreating buckets for every test + rspec_config.before(:each, :fog_reset) do + Fog::Mock.reset if Fog.mock? CloudController::DependencyLocator.instance.droplet_blobstore.ensure_bucket_exists CloudController::DependencyLocator.instance.package_blobstore.ensure_bucket_exists CloudController::DependencyLocator.instance.global_app_bits_cache.ensure_bucket_exists CloudController::DependencyLocator.instance.buildpack_blobstore.ensure_bucket_exists end - - VCAP::CloudController::SecurityContext.clear - VCAP::Request.current_id = nil - allow_any_instance_of(VCAP::CloudController::UaaTokenDecoder).to receive(:uaa_issuer).and_return(UAAIssuer::ISSUER) - - mock_redis = MockRedis.new - allow(Redis).to receive(:new).and_return(mock_redis) end rspec_config.around do |example| From daf0d9d8394cb25c9d76c8dc87d34ec38fd7bbd6 Mon Sep 17 00:00:00 2001 From: johha Date: Tue, 3 Mar 2026 08:18:37 +0100 Subject: [PATCH 16/20] Revert "Optimize spec_helper: skip Fog reset for most tests" This reverts commit 9bc28c18524c0e73a1fa8d0e063c75da863a0e4c. --- spec/spec_helper.rb | 27 ++++++++------------------- 1 file changed, 8 insertions(+), 19 deletions(-) diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 030aadaa1d7..8dd57e0bbfe 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -159,14 +159,6 @@ # calling this more than once will load tasks again and 'invoke' or 'execute' calls # will call rake tasks multiple times Application.load_tasks - - # Set up Fog mock buckets once at suite start instead of every test - if Fog.mock? - CloudController::DependencyLocator.instance.droplet_blobstore.ensure_bucket_exists - CloudController::DependencyLocator.instance.package_blobstore.ensure_bucket_exists - CloudController::DependencyLocator.instance.global_app_bits_cache.ensure_bucket_exists - CloudController::DependencyLocator.instance.buildpack_blobstore.ensure_bucket_exists - end end rspec_config.before do @@ -177,24 +169,21 @@ TestConfig.context = example.metadata[:job_context] || :api TestConfig.reset - VCAP::CloudController::SecurityContext.clear - VCAP::Request.current_id = nil - allow_any_instance_of(VCAP::CloudController::UaaTokenDecoder).to receive(:uaa_issuer).and_return(UAAIssuer::ISSUER) - - mock_redis = MockRedis.new - allow(Redis).to receive(:new).and_return(mock_redis) - end - - # Only reset Fog mocks for tests that use blobstores (tagged with :fog_reset) - # This avoids the overhead of clearing and recreating buckets for every test - rspec_config.before(:each, :fog_reset) do Fog::Mock.reset + if Fog.mock? CloudController::DependencyLocator.instance.droplet_blobstore.ensure_bucket_exists CloudController::DependencyLocator.instance.package_blobstore.ensure_bucket_exists CloudController::DependencyLocator.instance.global_app_bits_cache.ensure_bucket_exists CloudController::DependencyLocator.instance.buildpack_blobstore.ensure_bucket_exists end + + VCAP::CloudController::SecurityContext.clear + VCAP::Request.current_id = nil + allow_any_instance_of(VCAP::CloudController::UaaTokenDecoder).to receive(:uaa_issuer).and_return(UAAIssuer::ISSUER) + + mock_redis = MockRedis.new + allow(Redis).to receive(:new).and_return(mock_redis) end rspec_config.around do |example| From 99b2c57129e7c16b8dda8b13ea7befa85b770023 Mon Sep 17 00:00:00 2001 From: johha Date: Tue, 3 Mar 2026 08:33:25 +0100 Subject: [PATCH 17/20] Use fog_spec_helper for blobstore specs needing clean state Move Fog mock reset from global spec_helper to opt-in fog_spec_helper. This prevents fog reset from running for all specs, which caused conflicts with migration specs that stub Config.get differently. The fog_spec_helper now: - Uses metadata-based hook (:fog_isolation) that only runs for tagged specs - Resets Fog mocks and recreates buckets before each tagged test - Avoids interfering with specs that mock Config differently 24 blobstore-related specs are tagged with :fog_isolation metadata. --- spec/fog_spec_helper.rb | 21 +++++++++++++++++++ spec/spec_helper.rb | 18 ++++++++-------- .../runtime/buildpack_bits_controller_spec.rb | 4 ++-- .../runtime/buildpacks_controller_spec.rb | 4 ++-- .../runtime/stagings_controller_spec.rb | 4 ++-- .../jobs/runtime/blobstore_delete_spec.rb | 4 ++-- .../jobs/runtime/blobstore_upload_spec.rb | 4 ++-- .../runtime/buildpack_cache_cleanup_spec.rb | 4 ++-- .../jobs/v3/buildpack_cache_cleanup_spec.rb | 4 ++-- .../jobs/v3/buildpack_cache_delete_spec.rb | 4 ++-- .../jobs/v3/buildpack_cache_upload_spec.rb | 4 ++-- spec/unit/jobs/v3/droplet_bits_copier_spec.rb | 4 ++-- spec/unit/jobs/v3/droplet_upload_spec.rb | 4 ++-- spec/unit/jobs/v3/package_bits_copier_spec.rb | 4 ++-- .../blobstore/client_provider_spec.rb | 4 ++-- .../blobstore/error_handling_client_spec.rb | 4 ++-- .../blobstore/fog/fog_client_spec.rb | 4 ++-- .../blobstore/retryable_client_spec.rb | 4 ++-- .../blobstore/safe_delete_client_spec.rb | 4 ++-- .../storage_cli/storage_cli_client_spec.rb | 4 ++-- .../blobstore/webdav/dav_client_spec.rb | 4 ++-- .../packager/local_bits_packer_spec.rb | 4 ++-- .../cloud_controller/resource_pool_spec.rb | 4 ++-- .../cloud_controller/upload_buildpack_spec.rb | 4 ++-- .../runtime/buildpack_bits_delete_spec.rb | 4 ++-- spec/unit/models/runtime/buildpack_spec.rb | 4 ++-- 26 files changed, 78 insertions(+), 57 deletions(-) create mode 100644 spec/fog_spec_helper.rb diff --git a/spec/fog_spec_helper.rb b/spec/fog_spec_helper.rb new file mode 100644 index 00000000000..3d908888544 --- /dev/null +++ b/spec/fog_spec_helper.rb @@ -0,0 +1,21 @@ +# Use this helper for specs that need Fog/blobstore functionality with +# a clean state between tests (upload, download, delete operations). +# +# This helper resets Fog mocks and recreates buckets before each test. +# +# For specs that don't need blobstore isolation, use spec_helper instead. + +require 'spec_helper' + +RSpec.configure do |config| + config.before(:each, :fog_isolation) do + Fog::Mock.reset + + if Fog.mock? + CloudController::DependencyLocator.instance.droplet_blobstore.ensure_bucket_exists + CloudController::DependencyLocator.instance.package_blobstore.ensure_bucket_exists + CloudController::DependencyLocator.instance.global_app_bits_cache.ensure_bucket_exists + CloudController::DependencyLocator.instance.buildpack_blobstore.ensure_bucket_exists + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 8dd57e0bbfe..e646360cbbd 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -159,6 +159,15 @@ # calling this more than once will load tasks again and 'invoke' or 'execute' calls # will call rake tasks multiple times Application.load_tasks + + # Initialize Fog mock buckets once at suite start. + # Tests that need isolated/clean Fog state should use fog_spec_helper. + if Fog.mock? + CloudController::DependencyLocator.instance.droplet_blobstore.ensure_bucket_exists + CloudController::DependencyLocator.instance.package_blobstore.ensure_bucket_exists + CloudController::DependencyLocator.instance.global_app_bits_cache.ensure_bucket_exists + CloudController::DependencyLocator.instance.buildpack_blobstore.ensure_bucket_exists + end end rspec_config.before do @@ -169,15 +178,6 @@ TestConfig.context = example.metadata[:job_context] || :api TestConfig.reset - Fog::Mock.reset - - if Fog.mock? - CloudController::DependencyLocator.instance.droplet_blobstore.ensure_bucket_exists - CloudController::DependencyLocator.instance.package_blobstore.ensure_bucket_exists - CloudController::DependencyLocator.instance.global_app_bits_cache.ensure_bucket_exists - CloudController::DependencyLocator.instance.buildpack_blobstore.ensure_bucket_exists - end - VCAP::CloudController::SecurityContext.clear VCAP::Request.current_id = nil allow_any_instance_of(VCAP::CloudController::UaaTokenDecoder).to receive(:uaa_issuer).and_return(UAAIssuer::ISSUER) diff --git a/spec/unit/controllers/runtime/buildpack_bits_controller_spec.rb b/spec/unit/controllers/runtime/buildpack_bits_controller_spec.rb index a18fb1e7248..a372453849d 100644 --- a/spec/unit/controllers/runtime/buildpack_bits_controller_spec.rb +++ b/spec/unit/controllers/runtime/buildpack_bits_controller_spec.rb @@ -1,9 +1,9 @@ -require 'spec_helper' +require 'fog_spec_helper' ## NOTICE: Prefer request specs over controller specs as per ADR #0003 ## module VCAP::CloudController - RSpec.describe VCAP::CloudController::BuildpackBitsController do + RSpec.describe VCAP::CloudController::BuildpackBitsController, :fog_isolation do let(:user) { make_user } let(:filename) { 'file.zip' } let(:sha_valid_zip) { Digester.new(algorithm: OpenSSL::Digest::SHA256).digest_file(valid_zip) } diff --git a/spec/unit/controllers/runtime/buildpacks_controller_spec.rb b/spec/unit/controllers/runtime/buildpacks_controller_spec.rb index 0eecd522bdd..35f37501bc6 100644 --- a/spec/unit/controllers/runtime/buildpacks_controller_spec.rb +++ b/spec/unit/controllers/runtime/buildpacks_controller_spec.rb @@ -1,9 +1,9 @@ -require 'spec_helper' +require 'fog_spec_helper' ## NOTICE: Prefer request specs over controller specs as per ADR #0003 ## module VCAP::CloudController - RSpec.describe VCAP::CloudController::BuildpacksController do + RSpec.describe VCAP::CloudController::BuildpacksController, :fog_isolation do def ordered_buildpacks Buildpack.order(:position).map { |bp| [bp.name, bp.position] } end diff --git a/spec/unit/controllers/runtime/stagings_controller_spec.rb b/spec/unit/controllers/runtime/stagings_controller_spec.rb index 2be7d38dc8c..e4e3324551b 100644 --- a/spec/unit/controllers/runtime/stagings_controller_spec.rb +++ b/spec/unit/controllers/runtime/stagings_controller_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'fog_spec_helper' ## NOTICE: Prefer request specs over controller specs as per ADR #0003 ## @@ -164,7 +164,7 @@ module VCAP::CloudController end end - RSpec.describe StagingsController do + RSpec.describe StagingsController, :fog_isolation do let(:timeout_in_seconds) { 120 } let(:cc_addr) { '1.2.3.4' } let(:cc_port) { 5678 } diff --git a/spec/unit/jobs/runtime/blobstore_delete_spec.rb b/spec/unit/jobs/runtime/blobstore_delete_spec.rb index df7e32fd2fa..8e53c2ffdbf 100644 --- a/spec/unit/jobs/runtime/blobstore_delete_spec.rb +++ b/spec/unit/jobs/runtime/blobstore_delete_spec.rb @@ -1,8 +1,8 @@ -require 'spec_helper' +require 'fog_spec_helper' module VCAP::CloudController module Jobs::Runtime - RSpec.describe BlobstoreDelete, job_context: :worker do + RSpec.describe BlobstoreDelete, :fog_isolation, job_context: :worker do let(:key) { 'key' } subject(:job) do BlobstoreDelete.new(key, :droplet_blobstore) diff --git a/spec/unit/jobs/runtime/blobstore_upload_spec.rb b/spec/unit/jobs/runtime/blobstore_upload_spec.rb index 1e4514b2cd5..73c991f5711 100644 --- a/spec/unit/jobs/runtime/blobstore_upload_spec.rb +++ b/spec/unit/jobs/runtime/blobstore_upload_spec.rb @@ -1,8 +1,8 @@ -require 'spec_helper' +require 'fog_spec_helper' module VCAP::CloudController module Jobs::Runtime - RSpec.describe BlobstoreUpload, job_context: :worker do + RSpec.describe BlobstoreUpload, :fog_isolation, job_context: :worker do let(:local_file) { Tempfile.new('tmpfile') } let(:blobstore_key) { 'key' } let(:blobstore_name) { :droplet_blobstore } diff --git a/spec/unit/jobs/runtime/buildpack_cache_cleanup_spec.rb b/spec/unit/jobs/runtime/buildpack_cache_cleanup_spec.rb index e4c75cb56ef..27e98e62725 100644 --- a/spec/unit/jobs/runtime/buildpack_cache_cleanup_spec.rb +++ b/spec/unit/jobs/runtime/buildpack_cache_cleanup_spec.rb @@ -1,8 +1,8 @@ -require 'spec_helper' +require 'fog_spec_helper' module VCAP::CloudController module Jobs::Runtime - RSpec.describe BuildpackCacheCleanup, job_context: :worker do + RSpec.describe BuildpackCacheCleanup, :fog_isolation, job_context: :worker do let(:cc_addr) { '1.2.3.4' } let(:cc_port) { 5678 } let(:orphan_key) { 'orphan-key' } diff --git a/spec/unit/jobs/v3/buildpack_cache_cleanup_spec.rb b/spec/unit/jobs/v3/buildpack_cache_cleanup_spec.rb index eabeb77fdc2..6e82fb763a3 100644 --- a/spec/unit/jobs/v3/buildpack_cache_cleanup_spec.rb +++ b/spec/unit/jobs/v3/buildpack_cache_cleanup_spec.rb @@ -1,8 +1,8 @@ -require 'spec_helper' +require 'fog_spec_helper' module VCAP::CloudController module Jobs::V3 - RSpec.describe BuildpackCacheCleanup, job_context: :worker do + RSpec.describe BuildpackCacheCleanup, :fog_isolation, job_context: :worker do let(:cc_addr) { '1.2.3.4' } let(:cc_port) { 5678 } let(:orphan_key) { 'orphan-key' } diff --git a/spec/unit/jobs/v3/buildpack_cache_delete_spec.rb b/spec/unit/jobs/v3/buildpack_cache_delete_spec.rb index 3b51081a117..ca66770d965 100644 --- a/spec/unit/jobs/v3/buildpack_cache_delete_spec.rb +++ b/spec/unit/jobs/v3/buildpack_cache_delete_spec.rb @@ -1,9 +1,9 @@ -require 'spec_helper' +require 'fog_spec_helper' require 'jobs/v3/buildpack_cache_delete' module VCAP::CloudController module Jobs::V3 - RSpec.describe BuildpackCacheDelete, job_context: :worker do + RSpec.describe BuildpackCacheDelete, :fog_isolation, job_context: :worker do let(:app_guid) { 'some-guid' } let(:local_dir) { Dir.mktmpdir } let!(:blobstore) do diff --git a/spec/unit/jobs/v3/buildpack_cache_upload_spec.rb b/spec/unit/jobs/v3/buildpack_cache_upload_spec.rb index ec0a7947a6c..67cbf088aaa 100644 --- a/spec/unit/jobs/v3/buildpack_cache_upload_spec.rb +++ b/spec/unit/jobs/v3/buildpack_cache_upload_spec.rb @@ -1,8 +1,8 @@ -require 'spec_helper' +require 'fog_spec_helper' module VCAP::CloudController module Jobs::V3 - RSpec.describe BuildpackCacheUpload, job_context: :api do + RSpec.describe BuildpackCacheUpload, :fog_isolation, job_context: :api do subject(:job) { BuildpackCacheUpload.new(local_path: local_file.path, app_guid: app.guid, stack_name: 'some-stack') } let(:app) { AppModel.make(:buildpack) } diff --git a/spec/unit/jobs/v3/droplet_bits_copier_spec.rb b/spec/unit/jobs/v3/droplet_bits_copier_spec.rb index f647632a2e8..b4e06595dcb 100644 --- a/spec/unit/jobs/v3/droplet_bits_copier_spec.rb +++ b/spec/unit/jobs/v3/droplet_bits_copier_spec.rb @@ -1,8 +1,8 @@ -require 'spec_helper' +require 'fog_spec_helper' module VCAP::CloudController module Jobs::V3 - RSpec.describe DropletBitsCopier do + RSpec.describe DropletBitsCopier, :fog_isolation do subject(:job) { DropletBitsCopier.new(source_droplet.guid, destination_droplet.guid) } let(:droplet_bits_path) { File.expand_path('../../../fixtures/good.zip', File.dirname(__FILE__)) } diff --git a/spec/unit/jobs/v3/droplet_upload_spec.rb b/spec/unit/jobs/v3/droplet_upload_spec.rb index 08f30bde7b3..ae323e81a18 100644 --- a/spec/unit/jobs/v3/droplet_upload_spec.rb +++ b/spec/unit/jobs/v3/droplet_upload_spec.rb @@ -1,8 +1,8 @@ -require 'spec_helper' +require 'fog_spec_helper' module VCAP::CloudController module Jobs::V3 - RSpec.describe DropletUpload, job_context: :api do + RSpec.describe DropletUpload, :fog_isolation, job_context: :api do let(:droplet) { DropletModel.make(state: 'STAGING', droplet_hash: nil, sha256_checksum: nil, app: nil) } let(:file_content) { 'some_file_content' } let(:local_file) do diff --git a/spec/unit/jobs/v3/package_bits_copier_spec.rb b/spec/unit/jobs/v3/package_bits_copier_spec.rb index 46ff1d76dc1..f81419e8057 100644 --- a/spec/unit/jobs/v3/package_bits_copier_spec.rb +++ b/spec/unit/jobs/v3/package_bits_copier_spec.rb @@ -1,8 +1,8 @@ -require 'spec_helper' +require 'fog_spec_helper' module VCAP::CloudController module Jobs::V3 - RSpec.describe PackageBitsCopier, job_context: :worker do + RSpec.describe PackageBitsCopier, :fog_isolation, job_context: :worker do subject(:job) { PackageBitsCopier.new(source_package.guid, destination_package.guid) } let(:package_bits_path) { File.expand_path('../../../fixtures/good.zip', File.dirname(__FILE__)) } diff --git a/spec/unit/lib/cloud_controller/blobstore/client_provider_spec.rb b/spec/unit/lib/cloud_controller/blobstore/client_provider_spec.rb index aef74733752..f3b4843b4ee 100644 --- a/spec/unit/lib/cloud_controller/blobstore/client_provider_spec.rb +++ b/spec/unit/lib/cloud_controller/blobstore/client_provider_spec.rb @@ -1,8 +1,8 @@ -require 'spec_helper' +require 'fog_spec_helper' module CloudController module Blobstore - RSpec.describe ClientProvider do + RSpec.describe ClientProvider, :fog_isolation do let(:options) { { blobstore_type: } } context 'when no type is requested' do diff --git a/spec/unit/lib/cloud_controller/blobstore/error_handling_client_spec.rb b/spec/unit/lib/cloud_controller/blobstore/error_handling_client_spec.rb index 760306a8457..53eeb35e970 100644 --- a/spec/unit/lib/cloud_controller/blobstore/error_handling_client_spec.rb +++ b/spec/unit/lib/cloud_controller/blobstore/error_handling_client_spec.rb @@ -1,11 +1,11 @@ -require 'spec_helper' +require 'fog_spec_helper' require_relative 'client_shared' require 'cloud_controller/blobstore/error_handling_client' require 'cloud_controller/blobstore/null_client' module CloudController module Blobstore - RSpec.describe ErrorHandlingClient do + RSpec.describe ErrorHandlingClient, :fog_isolation do subject(:client) { ErrorHandlingClient.new(wrapped_client) } let(:wrapped_client) { Blobstore::NullClient.new } let(:logger) { instance_double(Steno::Logger, error: nil) } diff --git a/spec/unit/lib/cloud_controller/blobstore/fog/fog_client_spec.rb b/spec/unit/lib/cloud_controller/blobstore/fog/fog_client_spec.rb index 92dc3492517..435605fcb1f 100644 --- a/spec/unit/lib/cloud_controller/blobstore/fog/fog_client_spec.rb +++ b/spec/unit/lib/cloud_controller/blobstore/fog/fog_client_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'fog_spec_helper' require 'webrick' require_relative '../client_shared' require 'fog/aws/models/storage/files' @@ -6,7 +6,7 @@ module CloudController module Blobstore - RSpec.describe FogClient do + RSpec.describe FogClient, :fog_isolation do let(:content) { 'Some Nonsense' } let(:sha_of_content) { Digester.new.digest(content) } let(:local_dir) { Dir.mktmpdir } diff --git a/spec/unit/lib/cloud_controller/blobstore/retryable_client_spec.rb b/spec/unit/lib/cloud_controller/blobstore/retryable_client_spec.rb index b7e3de44b00..ebf430ca567 100644 --- a/spec/unit/lib/cloud_controller/blobstore/retryable_client_spec.rb +++ b/spec/unit/lib/cloud_controller/blobstore/retryable_client_spec.rb @@ -1,11 +1,11 @@ -require 'spec_helper' +require 'fog_spec_helper' require 'cloud_controller/blobstore/retryable_client' require 'cloud_controller/blobstore/null_client' require_relative 'client_shared' module CloudController module Blobstore - RSpec.describe RetryableClient do + RSpec.describe RetryableClient, :fog_isolation do subject(:client) do RetryableClient.new( client: wrapped_client, diff --git a/spec/unit/lib/cloud_controller/blobstore/safe_delete_client_spec.rb b/spec/unit/lib/cloud_controller/blobstore/safe_delete_client_spec.rb index ae7cbe2b073..159117caf36 100644 --- a/spec/unit/lib/cloud_controller/blobstore/safe_delete_client_spec.rb +++ b/spec/unit/lib/cloud_controller/blobstore/safe_delete_client_spec.rb @@ -1,10 +1,10 @@ -require 'spec_helper' +require 'fog_spec_helper' require 'cloud_controller/blobstore/null_client' require_relative 'client_shared' module CloudController module Blobstore - RSpec.describe SafeDeleteClient do + RSpec.describe SafeDeleteClient, :fog_isolation do subject(:client) { SafeDeleteClient.new(wrapped_client, root_dir) } let(:wrapped_client) { NullClient.new } let(:root_dir) { 'root-dir' } diff --git a/spec/unit/lib/cloud_controller/blobstore/storage_cli/storage_cli_client_spec.rb b/spec/unit/lib/cloud_controller/blobstore/storage_cli/storage_cli_client_spec.rb index 6fff02091ff..bab0cd5c9ae 100644 --- a/spec/unit/lib/cloud_controller/blobstore/storage_cli/storage_cli_client_spec.rb +++ b/spec/unit/lib/cloud_controller/blobstore/storage_cli/storage_cli_client_spec.rb @@ -1,9 +1,9 @@ -require 'spec_helper' +require 'fog_spec_helper' require 'cloud_controller/blobstore/storage_cli/storage_cli_client' module CloudController module Blobstore - RSpec.describe StorageCliClient do + RSpec.describe StorageCliClient, :fog_isolation do describe 'client init' do it 'init the correct client when JSON has provider AzureRM' do droplets_cfg = Tempfile.new(['droplets', '.json']) diff --git a/spec/unit/lib/cloud_controller/blobstore/webdav/dav_client_spec.rb b/spec/unit/lib/cloud_controller/blobstore/webdav/dav_client_spec.rb index f4caad00c4b..8edb8ada2a0 100644 --- a/spec/unit/lib/cloud_controller/blobstore/webdav/dav_client_spec.rb +++ b/spec/unit/lib/cloud_controller/blobstore/webdav/dav_client_spec.rb @@ -1,9 +1,9 @@ -require 'spec_helper' +require 'fog_spec_helper' require_relative '../client_shared' module CloudController module Blobstore - RSpec.describe DavClient do + RSpec.describe DavClient, :fog_isolation do subject(:client) do DavClient.new( directory_key: directory_key, diff --git a/spec/unit/lib/cloud_controller/packager/local_bits_packer_spec.rb b/spec/unit/lib/cloud_controller/packager/local_bits_packer_spec.rb index 1b0846a6e77..ffbd0baa065 100644 --- a/spec/unit/lib/cloud_controller/packager/local_bits_packer_spec.rb +++ b/spec/unit/lib/cloud_controller/packager/local_bits_packer_spec.rb @@ -1,8 +1,8 @@ -require 'spec_helper' +require 'fog_spec_helper' require 'cloud_controller/packager/local_bits_packer' module CloudController::Packager - RSpec.describe LocalBitsPacker do + RSpec.describe LocalBitsPacker, :fog_isolation do subject(:packer) { LocalBitsPacker.new } let(:uploaded_files_path) { File.join(local_tmp_dir, 'good.zip') } diff --git a/spec/unit/lib/cloud_controller/resource_pool_spec.rb b/spec/unit/lib/cloud_controller/resource_pool_spec.rb index fab2ad6c253..1711ae48360 100644 --- a/spec/unit/lib/cloud_controller/resource_pool_spec.rb +++ b/spec/unit/lib/cloud_controller/resource_pool_spec.rb @@ -1,7 +1,7 @@ -require 'spec_helper' +require 'fog_spec_helper' module VCAP::CloudController - RSpec.describe ResourcePool do + RSpec.describe ResourcePool, :fog_isolation do include_context 'resource pool' describe '#match_resources' do diff --git a/spec/unit/lib/cloud_controller/upload_buildpack_spec.rb b/spec/unit/lib/cloud_controller/upload_buildpack_spec.rb index 507f56f9abd..8b3eb2c621a 100644 --- a/spec/unit/lib/cloud_controller/upload_buildpack_spec.rb +++ b/spec/unit/lib/cloud_controller/upload_buildpack_spec.rb @@ -1,7 +1,7 @@ -require 'spec_helper' +require 'fog_spec_helper' module VCAP::CloudController - RSpec.describe UploadBuildpack do + RSpec.describe UploadBuildpack, :fog_isolation do let(:buildpack_blobstore) { double(:buildpack_blobstore).as_null_object } let!(:buildpack) { VCAP::CloudController::Buildpack.create_from_hash({ name: 'upload_binary_buildpack', stack: 'cider', position: 0 }) } diff --git a/spec/unit/models/runtime/buildpack_bits_delete_spec.rb b/spec/unit/models/runtime/buildpack_bits_delete_spec.rb index 17d7ae468d7..695b07496b8 100644 --- a/spec/unit/models/runtime/buildpack_bits_delete_spec.rb +++ b/spec/unit/models/runtime/buildpack_bits_delete_spec.rb @@ -1,7 +1,7 @@ -require 'spec_helper' +require 'fog_spec_helper' module VCAP::CloudController - RSpec.describe BuildpackBitsDelete do + RSpec.describe BuildpackBitsDelete, :fog_isolation do let(:staging_timeout) { 144 } let(:key) { 'key' } let!(:blobstore) do diff --git a/spec/unit/models/runtime/buildpack_spec.rb b/spec/unit/models/runtime/buildpack_spec.rb index 200c8645a13..6fcdf3dd743 100644 --- a/spec/unit/models/runtime/buildpack_spec.rb +++ b/spec/unit/models/runtime/buildpack_spec.rb @@ -1,7 +1,7 @@ -require 'spec_helper' +require 'fog_spec_helper' module VCAP::CloudController - RSpec.describe Buildpack, type: :model do + RSpec.describe Buildpack, :fog_isolation, type: :model do def ordered_buildpacks Buildpack.order(:position).map { |bp| [bp.name, bp.position] } end From 28fb5ac8e071fdcab28c7a1dd987eccc3abb50fc Mon Sep 17 00:00:00 2001 From: johha Date: Tue, 3 Mar 2026 10:11:19 +0100 Subject: [PATCH 18/20] Remove legacy Spork code from spec_helper Spork is no longer used in this project. This removes: - The require 'spork' with rescue block - The shell command that checked for running spork processes - The Spork-related documentation/instructions - The conditional Spork.prefork/each_run blocks The init_block and each_run_block are now called directly. --- spec/spec_helper.rb | 56 ++------------------------------------------- 1 file changed, 2 insertions(+), 54 deletions(-) diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index e646360cbbd..81de6bef119 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -2,45 +2,6 @@ require 'rubygems' require 'mock_redis' -begin - require 'spork' - # uncomment the following line to use spork with the debugger - # require 'spork/ext/ruby-debug' - - run_spork = !`ps | grep spork | grep -v grep`.empty? -rescue LoadError - run_spork = false -end - -# --- Instructions --- -# Sort the contents of this file into a Spork.prefork and a Spork.each_run -# block. -# -# The Spork.prefork block is run only once when the spork server is started. -# You typically want to place most of your (slow) initializer code in here, in -# particular, require'ing any 3rd-party gems that you don't normally modify -# during development. -# -# The Spork.each_run block is run each time you run your specs. In case you -# need to load files that tend to change during development, require them here. -# With Rails, your application modules are loaded automatically, so sometimes -# this block can remain empty. -# -# Note: You can modify files loaded *from* the Spork.each_run block without -# restarting the spork server. However, this file itself will not be reloaded, -# so if you change any of the code inside the each_run block, you still need to -# restart the server. In general, if you have non-trivial code in this file, -# it's advisable to move it into a separate file so you can easily edit it -# without restarting spork. (For example, with RSpec, you could move -# non-trivial code into a file spec/support/my_helper.rb, making sure that the -# spec/support/* files are require'd from inside the each_run block.) -# -# Any code that is left outside the two blocks will be run during preforking -# *and* during each_run -- that's probably not what you want. -# -# These instructions should self-destruct in 10 seconds. If they don't, feel -# free to delete them. - init_block = proc do $LOAD_PATH.push(File.expand_path(__dir__)) @@ -219,18 +180,5 @@ end end -if run_spork - Spork.prefork do - # Loading more in this block will cause your tests to run faster. However, - # if you change any configuration or code from libraries loaded here, you'll - # need to restart spork for it to take effect. - init_block.call - end - Spork.each_run do - # This code will be run each time you run your specs. - each_run_block.call - end -else - init_block.call - each_run_block.call -end +init_block.call +each_run_block.call From 34d3c36745553147ce2d9114b2686590c69135a3 Mon Sep 17 00:00:00 2001 From: johha Date: Tue, 3 Mar 2026 10:55:28 +0100 Subject: [PATCH 19/20] Optimize TestConfig.reset to only run when context changes TestConfig.reset is expensive (~1.9ms per call) as it reloads config from file and resets all dependencies. Previously it ran for every test, but only ~57 specs use non-default job_context. Now TestConfig.reset only runs when the context actually changes: - First test: reset happens (context transitions from nil to :api) - Subsequent :api tests: no reset needed - Tests with :worker or :clock context: reset only on context change This saves ~1.9ms per test for the vast majority of specs. --- spec/spec_helper.rb | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 81de6bef119..7586fa878a0 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -136,8 +136,11 @@ Sequel::Deprecation.output = StringIO.new Sequel::Deprecation.backtrace_filter = 5 - TestConfig.context = example.metadata[:job_context] || :api - TestConfig.reset + new_context = example.metadata[:job_context] || :api + if TestConfig.context != new_context + TestConfig.context = new_context + TestConfig.reset + end VCAP::CloudController::SecurityContext.clear VCAP::Request.current_id = nil From ca829221997c877c11ded27c787aa329a8ec2b62 Mon Sep 17 00:00:00 2001 From: johha Date: Tue, 3 Mar 2026 13:17:50 +0100 Subject: [PATCH 20/20] Revert "Optimize TestConfig.reset to only run when context changes" This reverts the optimization because it broke test isolation. Tests use Config.config.set() to override config values, and without TestConfig.reset between tests, these changes persist and pollute subsequent tests. For example, tests setting max_annotations_per_resource=1 caused other tests to fail with "AnnotationLimitExceeded" errors. --- spec/spec_helper.rb | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 7586fa878a0..81de6bef119 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -136,11 +136,8 @@ Sequel::Deprecation.output = StringIO.new Sequel::Deprecation.backtrace_filter = 5 - new_context = example.metadata[:job_context] || :api - if TestConfig.context != new_context - TestConfig.context = new_context - TestConfig.reset - end + TestConfig.context = example.metadata[:job_context] || :api + TestConfig.reset VCAP::CloudController::SecurityContext.clear VCAP::Request.current_id = nil