From 5fc7135b503e4010114071b5946b6a3e3de68f57 Mon Sep 17 00:00:00 2001 From: johha Date: Mon, 2 Mar 2026 13:43:52 +0100 Subject: [PATCH 01/16] 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 756eaa6745a86a6c8ee94f4c4d51bdbae37a21c4 Mon Sep 17 00:00:00 2001 From: johha Date: Mon, 2 Mar 2026 13:47:10 +0100 Subject: [PATCH 02/16] 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 7c8266e830ae1dd5a4efa6e73ddd689641597f9d Mon Sep 17 00:00:00 2001 From: johha Date: Mon, 2 Mar 2026 13:57:04 +0100 Subject: [PATCH 03/16] 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 8e5bbd62972cf8ac42c7e9b3c8f8f6a32404d614 Mon Sep 17 00:00:00 2001 From: johha Date: Mon, 2 Mar 2026 14:00:13 +0100 Subject: [PATCH 04/16] 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 a48588e40dbc4781c4cc78de5612e3af5e915696 Mon Sep 17 00:00:00 2001 From: johha Date: Mon, 2 Mar 2026 14:30:24 +0100 Subject: [PATCH 05/16] 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 92c1ae598c0d61f1ca04f82498d587f7a24d4fa1 Mon Sep 17 00:00:00 2001 From: johha Date: Mon, 2 Mar 2026 14:38:06 +0100 Subject: [PATCH 06/16] 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 bda5dce8cf09219c47af24010478a5ff5d463a4e Mon Sep 17 00:00:00 2001 From: johha Date: Mon, 2 Mar 2026 14:43:02 +0100 Subject: [PATCH 07/16] 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 1b6a5a8be2e72f2ec9503472e0b5ab2f0a975824 Mon Sep 17 00:00:00 2001 From: johha Date: Mon, 2 Mar 2026 14:56:49 +0100 Subject: [PATCH 08/16] 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 66409b3ffffc714e412cb88d04e376c33c890646 Mon Sep 17 00:00:00 2001 From: johha Date: Mon, 2 Mar 2026 15:04:35 +0100 Subject: [PATCH 09/16] 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 3cbe01330fdd071be579250167b06314036b454d Mon Sep 17 00:00:00 2001 From: johha Date: Mon, 2 Mar 2026 15:41:51 +0100 Subject: [PATCH 10/16] 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 66d7464fe34fca1539b2530d371243b456b5fd27 Mon Sep 17 00:00:00 2001 From: johha Date: Mon, 2 Mar 2026 15:45:13 +0100 Subject: [PATCH 11/16] 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 9f97f0097344dec26935f8a25dd5fc9d20f10158 Mon Sep 17 00:00:00 2001 From: johha Date: Mon, 2 Mar 2026 16:18:09 +0100 Subject: [PATCH 12/16] 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 5cf756771efadf64cc521f5f10ac6d102f1819d5 Mon Sep 17 00:00:00 2001 From: johha Date: Mon, 2 Mar 2026 17:10:24 +0100 Subject: [PATCH 13/16] 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 35ae07f428a03ccd5cb425ca7a825b83e4a8a0dc Mon Sep 17 00:00:00 2001 From: johha Date: Mon, 2 Mar 2026 17:24:39 +0100 Subject: [PATCH 14/16] 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 9c6c53eee2433bdb269152c79f173f55412b58ba Mon Sep 17 00:00:00 2001 From: johha Date: Tue, 3 Mar 2026 08:33:25 +0100 Subject: [PATCH 15/16] 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 +++++++++++++++++++ .../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 ++-- 25 files changed, 69 insertions(+), 48 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/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 ead5a6e64f8..1c88b282461 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 # DEPRECATED: Legacy fog provider tests - remove after migration window # START LEGACY FOG SUPPORT TESTS 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 ef56d24e53b7045e3ea2bfa35329e8287a9ce95d Mon Sep 17 00:00:00 2001 From: johha Date: Fri, 13 Mar 2026 15:54:54 +0100 Subject: [PATCH 16/16] Convert 33 specs to lightweight_spec_helper for faster test loading This batch converts specs that test pure Ruby logic without database dependencies, allowing them to load in ~0.6s instead of ~10s. Converted lib specs (24 files, ~240 tests): - Diego lifecycle data specs (buildpack, cnb, docker) - Diego utilities (failure_reason_sanitizer, ssh_key, droplet_url_generator, docker_uri_converter) - Core utilities (http_response_error, http_request_error, structured_error, index_stopper) - Routing/paging (router_group, disabled_routing_api_client, pagination_options) - Other utils (uri_utils, digester, adjective_noun_generator, database_uri_generator, etc.) Converted message specs (9 files, ~111 tests): - List message specs: processes, stacks, spaces, builds, deployments, droplets, events, tasks, sidecars Also fixed: - validators_spec.rb: Added Config stub for LifecycleValidator tests Load time improvement: ~10x faster for converted specs --- .../lib/cloud_controller/adjective_noun_generator_spec.rb | 2 +- .../blob_sender/default_blob_sender_spec.rb | 3 ++- .../cloud_controller/clock/distributed_scheduler_spec.rb | 3 ++- .../lib/cloud_controller/database_uri_generator_spec.rb | 3 ++- .../cloud_controller/diego/buildpack/lifecycle_data_spec.rb | 3 ++- .../lib/cloud_controller/diego/cnb/lifecycle_data_spec.rb | 5 +++-- .../diego/docker/docker_uri_converter_spec.rb | 3 ++- .../cloud_controller/diego/docker/lifecycle_data_spec.rb | 3 ++- .../cloud_controller/diego/droplet_url_generator_spec.rb | 2 +- .../cloud_controller/diego/failure_reason_sanitizer_spec.rb | 2 +- spec/unit/lib/cloud_controller/diego/ssh_key_spec.rb | 2 +- .../lib/cloud_controller/metrics/request_metrics_spec.rb | 2 +- .../lib/cloud_controller/paging/pagination_options_spec.rb | 2 +- .../routing_api/disabled_routing_api_client_spec.rb | 3 ++- .../lib/cloud_controller/routing_api/router_group_spec.rb | 2 +- spec/unit/lib/http_request_error_spec.rb | 4 +++- spec/unit/lib/http_response_error_spec.rb | 4 +++- spec/unit/lib/index_stopper_spec.rb | 3 ++- spec/unit/lib/rest_controller/common_params_spec.rb | 3 ++- spec/unit/lib/structured_error_spec.rb | 3 ++- spec/unit/lib/utils/uri_utils_spec.rb | 2 +- spec/unit/lib/vcap/digester_spec.rb | 2 +- spec/unit/lib/vcap/host_system_spec.rb | 2 +- spec/unit/messages/builds_list_message_spec.rb | 2 +- spec/unit/messages/deployments_list_message_spec.rb | 2 +- spec/unit/messages/droplets_list_message_spec.rb | 3 ++- spec/unit/messages/events_list_message_spec.rb | 2 +- spec/unit/messages/processes_list_message_spec.rb | 2 +- spec/unit/messages/sidecars_list_message_spec.rb | 2 +- spec/unit/messages/spaces_list_message_spec.rb | 2 +- spec/unit/messages/stacks_list_message_spec.rb | 2 +- spec/unit/messages/tasks_list_message_spec.rb | 3 ++- spec/unit/messages/validators_spec.rb | 6 ++++++ 33 files changed, 56 insertions(+), 33 deletions(-) diff --git a/spec/unit/lib/cloud_controller/adjective_noun_generator_spec.rb b/spec/unit/lib/cloud_controller/adjective_noun_generator_spec.rb index 5194bea091f..502cdb9f1f8 100644 --- a/spec/unit/lib/cloud_controller/adjective_noun_generator_spec.rb +++ b/spec/unit/lib/cloud_controller/adjective_noun_generator_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'cloud_controller/adjective_noun_generator' module VCAP::CloudController diff --git a/spec/unit/lib/cloud_controller/blob_sender/default_blob_sender_spec.rb b/spec/unit/lib/cloud_controller/blob_sender/default_blob_sender_spec.rb index b1e20be27f3..51e9d63e355 100644 --- a/spec/unit/lib/cloud_controller/blob_sender/default_blob_sender_spec.rb +++ b/spec/unit/lib/cloud_controller/blob_sender/default_blob_sender_spec.rb @@ -1,4 +1,5 @@ -require 'spec_helper' +require 'lightweight_spec_helper' +require 'steno/steno' require 'cloud_controller/blob_sender/default_blob_sender' module CloudController diff --git a/spec/unit/lib/cloud_controller/clock/distributed_scheduler_spec.rb b/spec/unit/lib/cloud_controller/clock/distributed_scheduler_spec.rb index d5f759a5044..86442b26224 100644 --- a/spec/unit/lib/cloud_controller/clock/distributed_scheduler_spec.rb +++ b/spec/unit/lib/cloud_controller/clock/distributed_scheduler_spec.rb @@ -1,4 +1,5 @@ -require 'spec_helper' +require 'lightweight_spec_helper' +require 'clockwork' require 'cloud_controller/clock/distributed_scheduler' module VCAP::CloudController diff --git a/spec/unit/lib/cloud_controller/database_uri_generator_spec.rb b/spec/unit/lib/cloud_controller/database_uri_generator_spec.rb index c0ae0ae68e4..e783d3c8e2b 100644 --- a/spec/unit/lib/cloud_controller/database_uri_generator_spec.rb +++ b/spec/unit/lib/cloud_controller/database_uri_generator_spec.rb @@ -1,4 +1,5 @@ -require 'spec_helper' +require 'lightweight_spec_helper' +require 'cloud_controller/database_uri_generator' RSpec.describe VCAP::CloudController::DatabaseUriGenerator do let(:service_uris) { ['postgres://username:password@host/db'] } diff --git a/spec/unit/lib/cloud_controller/diego/buildpack/lifecycle_data_spec.rb b/spec/unit/lib/cloud_controller/diego/buildpack/lifecycle_data_spec.rb index c534c0b1f48..f1b170dba67 100644 --- a/spec/unit/lib/cloud_controller/diego/buildpack/lifecycle_data_spec.rb +++ b/spec/unit/lib/cloud_controller/diego/buildpack/lifecycle_data_spec.rb @@ -1,4 +1,5 @@ -require 'spec_helper' +require 'lightweight_spec_helper' +require 'membrane' require 'cloud_controller/diego/buildpack/lifecycle_data' module VCAP::CloudController diff --git a/spec/unit/lib/cloud_controller/diego/cnb/lifecycle_data_spec.rb b/spec/unit/lib/cloud_controller/diego/cnb/lifecycle_data_spec.rb index 3966880741d..e4b60fca1d8 100644 --- a/spec/unit/lib/cloud_controller/diego/cnb/lifecycle_data_spec.rb +++ b/spec/unit/lib/cloud_controller/diego/cnb/lifecycle_data_spec.rb @@ -1,5 +1,6 @@ -require 'spec_helper' -require 'cloud_controller/diego/buildpack/lifecycle_data' +require 'lightweight_spec_helper' +require 'membrane' +require 'cloud_controller/diego/cnb/lifecycle_data' module VCAP::CloudController module Diego diff --git a/spec/unit/lib/cloud_controller/diego/docker/docker_uri_converter_spec.rb b/spec/unit/lib/cloud_controller/diego/docker/docker_uri_converter_spec.rb index 9b963dc1a46..c07b07f38d2 100644 --- a/spec/unit/lib/cloud_controller/diego/docker/docker_uri_converter_spec.rb +++ b/spec/unit/lib/cloud_controller/diego/docker/docker_uri_converter_spec.rb @@ -1,4 +1,5 @@ -require 'spec_helper' +require 'lightweight_spec_helper' +require 'addressable/uri' require 'cloud_controller/diego/docker/docker_uri_converter' require 'utils/uri_utils' diff --git a/spec/unit/lib/cloud_controller/diego/docker/lifecycle_data_spec.rb b/spec/unit/lib/cloud_controller/diego/docker/lifecycle_data_spec.rb index 8a0c9d9afe0..1df8a574ae1 100644 --- a/spec/unit/lib/cloud_controller/diego/docker/lifecycle_data_spec.rb +++ b/spec/unit/lib/cloud_controller/diego/docker/lifecycle_data_spec.rb @@ -1,4 +1,5 @@ -require 'spec_helper' +require 'lightweight_spec_helper' +require 'membrane' require 'cloud_controller/diego/docker/lifecycle_data' module VCAP::CloudController diff --git a/spec/unit/lib/cloud_controller/diego/droplet_url_generator_spec.rb b/spec/unit/lib/cloud_controller/diego/droplet_url_generator_spec.rb index b9764de77e9..8cfe984d7ac 100644 --- a/spec/unit/lib/cloud_controller/diego/droplet_url_generator_spec.rb +++ b/spec/unit/lib/cloud_controller/diego/droplet_url_generator_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'cloud_controller/diego/droplet_url_generator' module VCAP::CloudController diff --git a/spec/unit/lib/cloud_controller/diego/failure_reason_sanitizer_spec.rb b/spec/unit/lib/cloud_controller/diego/failure_reason_sanitizer_spec.rb index a97c5ee6c4b..5e26a0999ba 100644 --- a/spec/unit/lib/cloud_controller/diego/failure_reason_sanitizer_spec.rb +++ b/spec/unit/lib/cloud_controller/diego/failure_reason_sanitizer_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'cloud_controller/diego/failure_reason_sanitizer' module VCAP::CloudController diff --git a/spec/unit/lib/cloud_controller/diego/ssh_key_spec.rb b/spec/unit/lib/cloud_controller/diego/ssh_key_spec.rb index 36cc2df6ef6..0084c2846aa 100644 --- a/spec/unit/lib/cloud_controller/diego/ssh_key_spec.rb +++ b/spec/unit/lib/cloud_controller/diego/ssh_key_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'cloud_controller/diego/ssh_key' module VCAP::CloudController diff --git a/spec/unit/lib/cloud_controller/metrics/request_metrics_spec.rb b/spec/unit/lib/cloud_controller/metrics/request_metrics_spec.rb index 3266e2f34f4..cf7fca34819 100644 --- a/spec/unit/lib/cloud_controller/metrics/request_metrics_spec.rb +++ b/spec/unit/lib/cloud_controller/metrics/request_metrics_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'cloud_controller/metrics/request_metrics' module VCAP::CloudController::Metrics diff --git a/spec/unit/lib/cloud_controller/paging/pagination_options_spec.rb b/spec/unit/lib/cloud_controller/paging/pagination_options_spec.rb index a122662c729..5ac3363ada2 100644 --- a/spec/unit/lib/cloud_controller/paging/pagination_options_spec.rb +++ b/spec/unit/lib/cloud_controller/paging/pagination_options_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'cloud_controller/paging/pagination_options' module VCAP::CloudController diff --git a/spec/unit/lib/cloud_controller/routing_api/disabled_routing_api_client_spec.rb b/spec/unit/lib/cloud_controller/routing_api/disabled_routing_api_client_spec.rb index 111653c379d..32168346f6f 100644 --- a/spec/unit/lib/cloud_controller/routing_api/disabled_routing_api_client_spec.rb +++ b/spec/unit/lib/cloud_controller/routing_api/disabled_routing_api_client_spec.rb @@ -1,4 +1,5 @@ -require 'spec_helper' +require 'lightweight_spec_helper' +require 'cloud_controller/routing_api/disabled_routing_api_client' module VCAP::CloudController::RoutingApi RSpec.describe DisabledClient do diff --git a/spec/unit/lib/cloud_controller/routing_api/router_group_spec.rb b/spec/unit/lib/cloud_controller/routing_api/router_group_spec.rb index 6df136e4aad..eca14c11432 100644 --- a/spec/unit/lib/cloud_controller/routing_api/router_group_spec.rb +++ b/spec/unit/lib/cloud_controller/routing_api/router_group_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'cloud_controller/routing_api/router_group' module VCAP::CloudController::RoutingApi diff --git a/spec/unit/lib/http_request_error_spec.rb b/spec/unit/lib/http_request_error_spec.rb index 5b259a0a9c3..a0f3b0ad48c 100644 --- a/spec/unit/lib/http_request_error_spec.rb +++ b/spec/unit/lib/http_request_error_spec.rb @@ -1,4 +1,6 @@ -require 'spec_helper' +require 'lightweight_spec_helper' +require 'cloud_controller/structured_error' +require 'cloud_controller/http_request_error' RSpec.describe HttpRequestError do let(:endpoint) { 'http://www.example.com/' } diff --git a/spec/unit/lib/http_response_error_spec.rb b/spec/unit/lib/http_response_error_spec.rb index 3b8f4e92481..a91ec4569ef 100644 --- a/spec/unit/lib/http_response_error_spec.rb +++ b/spec/unit/lib/http_response_error_spec.rb @@ -1,4 +1,6 @@ -require 'spec_helper' +require 'lightweight_spec_helper' +require 'oj' +require 'cloud_controller/http_response_error' RSpec.describe HttpResponseError do describe '#initialize' do diff --git a/spec/unit/lib/index_stopper_spec.rb b/spec/unit/lib/index_stopper_spec.rb index bf5eb2ad45c..191fde5d888 100644 --- a/spec/unit/lib/index_stopper_spec.rb +++ b/spec/unit/lib/index_stopper_spec.rb @@ -1,4 +1,5 @@ -require 'spec_helper' +require 'lightweight_spec_helper' +require 'cloud_controller/index_stopper' module VCAP::CloudController RSpec.describe IndexStopper do diff --git a/spec/unit/lib/rest_controller/common_params_spec.rb b/spec/unit/lib/rest_controller/common_params_spec.rb index 00c7f2d0c07..a45572e5924 100644 --- a/spec/unit/lib/rest_controller/common_params_spec.rb +++ b/spec/unit/lib/rest_controller/common_params_spec.rb @@ -1,4 +1,5 @@ -require 'spec_helper' +require 'lightweight_spec_helper' +require 'cloud_controller/rest_controller/common_params' module VCAP::CloudController::RestController RSpec.describe CommonParams do diff --git a/spec/unit/lib/structured_error_spec.rb b/spec/unit/lib/structured_error_spec.rb index fcfde20d570..6addd1fc288 100644 --- a/spec/unit/lib/structured_error_spec.rb +++ b/spec/unit/lib/structured_error_spec.rb @@ -1,4 +1,5 @@ -require 'spec_helper' +require 'lightweight_spec_helper' +require 'cloud_controller/structured_error' RSpec.describe StructuredError do context 'with a hash source' do diff --git a/spec/unit/lib/utils/uri_utils_spec.rb b/spec/unit/lib/utils/uri_utils_spec.rb index a47fa3d05da..7f6b68c57fb 100644 --- a/spec/unit/lib/utils/uri_utils_spec.rb +++ b/spec/unit/lib/utils/uri_utils_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'utils/uri_utils' RSpec.describe UriUtils do diff --git a/spec/unit/lib/vcap/digester_spec.rb b/spec/unit/lib/vcap/digester_spec.rb index 8177c4ac488..f8777900783 100644 --- a/spec/unit/lib/vcap/digester_spec.rb +++ b/spec/unit/lib/vcap/digester_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'vcap/digester' RSpec.describe Digester do diff --git a/spec/unit/lib/vcap/host_system_spec.rb b/spec/unit/lib/vcap/host_system_spec.rb index 35f9db905b4..abe0538062d 100644 --- a/spec/unit/lib/vcap/host_system_spec.rb +++ b/spec/unit/lib/vcap/host_system_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'vcap/host_system' RSpec.describe VCAP::HostSystem do diff --git a/spec/unit/messages/builds_list_message_spec.rb b/spec/unit/messages/builds_list_message_spec.rb index ef1d204cb22..5ef331d1c1f 100644 --- a/spec/unit/messages/builds_list_message_spec.rb +++ b/spec/unit/messages/builds_list_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/builds_list_message' module VCAP::CloudController diff --git a/spec/unit/messages/deployments_list_message_spec.rb b/spec/unit/messages/deployments_list_message_spec.rb index 21ac5240c85..a644738eddc 100644 --- a/spec/unit/messages/deployments_list_message_spec.rb +++ b/spec/unit/messages/deployments_list_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/deployments_list_message' module VCAP::CloudController diff --git a/spec/unit/messages/droplets_list_message_spec.rb b/spec/unit/messages/droplets_list_message_spec.rb index 7968f110a3a..d1ec3120abe 100644 --- a/spec/unit/messages/droplets_list_message_spec.rb +++ b/spec/unit/messages/droplets_list_message_spec.rb @@ -1,5 +1,6 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/droplets_list_message' +require 'messages/spaces_list_message' module VCAP::CloudController RSpec.describe DropletsListMessage do diff --git a/spec/unit/messages/events_list_message_spec.rb b/spec/unit/messages/events_list_message_spec.rb index a602ecf7d3d..e83a7e7a6e2 100644 --- a/spec/unit/messages/events_list_message_spec.rb +++ b/spec/unit/messages/events_list_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/events_list_message' module VCAP::CloudController diff --git a/spec/unit/messages/processes_list_message_spec.rb b/spec/unit/messages/processes_list_message_spec.rb index 4346e772dc6..362ea0f226b 100644 --- a/spec/unit/messages/processes_list_message_spec.rb +++ b/spec/unit/messages/processes_list_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/processes_list_message' module VCAP::CloudController diff --git a/spec/unit/messages/sidecars_list_message_spec.rb b/spec/unit/messages/sidecars_list_message_spec.rb index 24026d54d1f..c80ac0f9335 100644 --- a/spec/unit/messages/sidecars_list_message_spec.rb +++ b/spec/unit/messages/sidecars_list_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/sidecars_list_message' module VCAP::CloudController diff --git a/spec/unit/messages/spaces_list_message_spec.rb b/spec/unit/messages/spaces_list_message_spec.rb index 887aa4cf46b..8282f3277c8 100644 --- a/spec/unit/messages/spaces_list_message_spec.rb +++ b/spec/unit/messages/spaces_list_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/spaces_list_message' module VCAP::CloudController diff --git a/spec/unit/messages/stacks_list_message_spec.rb b/spec/unit/messages/stacks_list_message_spec.rb index d4e8df06126..4ab4f35c5d7 100644 --- a/spec/unit/messages/stacks_list_message_spec.rb +++ b/spec/unit/messages/stacks_list_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/stacks_list_message' module VCAP::CloudController diff --git a/spec/unit/messages/tasks_list_message_spec.rb b/spec/unit/messages/tasks_list_message_spec.rb index 32177588787..21b08276b70 100644 --- a/spec/unit/messages/tasks_list_message_spec.rb +++ b/spec/unit/messages/tasks_list_message_spec.rb @@ -1,5 +1,6 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/tasks_list_message' +require 'messages/packages_list_message' module VCAP::CloudController RSpec.describe TasksListMessage do diff --git a/spec/unit/messages/validators_spec.rb b/spec/unit/messages/validators_spec.rb index 03e31652f02..046444a1b2e 100644 --- a/spec/unit/messages/validators_spec.rb +++ b/spec/unit/messages/validators_spec.rb @@ -6,6 +6,7 @@ require 'cloud_controller/diego/lifecycles/app_docker_lifecycle' require 'cloud_controller/diego/lifecycles/app_buildpack_lifecycle' require 'cloud_controller/diego/lifecycles/lifecycles' +require 'cloud_controller/config' require 'rspec/collection_matchers' require 'pry' @@ -428,6 +429,11 @@ def validate_each(record, attr_name, value) end describe 'LifecycleValidator' do + before do + config = instance_double(VCAP::CloudController::Config, get: 'buildpack') + allow(VCAP::CloudController::Config).to receive(:config).and_return(config) + end + let(:lifecycle_class) do Class.new(fake_class) do attr_accessor :lifecycle