From 23d0ddfb83fb2747b68eeac6c1f870f336294853 Mon Sep 17 00:00:00 2001 From: johha Date: Mon, 2 Mar 2026 13:43:52 +0100 Subject: [PATCH 01/20] test-opt: convert 8 message specs to lightweight_spec_helper Convert the following message specs to use lightweight_spec_helper instead of full spec_helper for faster test execution: - app_feature_update_message_spec.rb - app_show_message_spec.rb - buildpack_create_message_spec.rb - buildpack_update_message_spec.rb - domain_delete_shared_org_message_spec.rb - domain_show_message_spec.rb - domain_update_message_spec.rb - feature_flags_update_message_spec.rb These specs don't require database access, Config, Lifecycles, or the errors_on helper, making them suitable for lightweight testing. --- spec/unit/messages/app_feature_update_message_spec.rb | 2 +- spec/unit/messages/app_show_message_spec.rb | 3 ++- spec/unit/messages/buildpack_create_message_spec.rb | 2 +- spec/unit/messages/buildpack_update_message_spec.rb | 2 +- spec/unit/messages/domain_delete_shared_org_message_spec.rb | 2 +- spec/unit/messages/domain_show_message_spec.rb | 2 +- spec/unit/messages/domain_update_message_spec.rb | 2 +- spec/unit/messages/feature_flags_update_message_spec.rb | 2 +- 8 files changed, 9 insertions(+), 8 deletions(-) diff --git a/spec/unit/messages/app_feature_update_message_spec.rb b/spec/unit/messages/app_feature_update_message_spec.rb index 8c580b2aac..19b9d1178d 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 61c42b8ce0..5728293005 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 36f766a63f..c290aa55e4 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 c467f597e4..4effebc8d8 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 d1d140ea74..09bdb98773 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 a77e73f83f..fac785f6fa 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 49fc4c6688..f4b7458940 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 a60416e012..caad6e5976 100644 --- a/spec/unit/messages/feature_flags_update_message_spec.rb +++ b/spec/unit/messages/feature_flags_update_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/feature_flags_update_message' module VCAP::CloudController From 4d60583a79784b88f2a75fc686fca071736496ad Mon Sep 17 00:00:00 2001 From: johha Date: Mon, 2 Mar 2026 13:47:10 +0100 Subject: [PATCH 02/20] test-opt: convert 8 more message specs to lightweight_spec_helper Convert the following message specs to use lightweight_spec_helper: - isolation_segment_relationship_org_message_spec.rb - manifest_buildpack_message_spec.rb - manifest_process_update_message_spec.rb - manifest_service_binding_create_message_spec.rb - organization_quota_apply_message_spec.rb - organization_quotas_create_message_spec.rb - organization_quotas_update_message_spec.rb - package_update_message_spec.rb Total converted: 16 message specs --- .../messages/isolation_segment_relationship_org_message_spec.rb | 2 +- spec/unit/messages/manifest_buildpack_message_spec.rb | 2 +- spec/unit/messages/manifest_process_update_message_spec.rb | 2 +- .../messages/manifest_service_binding_create_message_spec.rb | 2 +- spec/unit/messages/organization_quota_apply_message_spec.rb | 2 +- spec/unit/messages/organization_quotas_create_message_spec.rb | 2 +- spec/unit/messages/organization_quotas_update_message_spec.rb | 2 +- spec/unit/messages/package_update_message_spec.rb | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/spec/unit/messages/isolation_segment_relationship_org_message_spec.rb b/spec/unit/messages/isolation_segment_relationship_org_message_spec.rb index 56d2237527..336c4834dc 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 5893616ee1..7e8e42a59e 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 f2d06e2de8..3be19f802a 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 0f130549c4..8382b42364 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 296233fafe..87be95c907 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 a784c1f605..1c42ce53ef 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 c6ebb798c2..24790e9520 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 b48536c265..2bc4d7492c 100644 --- a/spec/unit/messages/package_update_message_spec.rb +++ b/spec/unit/messages/package_update_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/package_update_message' module VCAP::CloudController From 7a7e5331351e6c4f091ed3d90261ed8e0b91a056 Mon Sep 17 00:00:00 2001 From: johha Date: Mon, 2 Mar 2026 13:57:04 +0100 Subject: [PATCH 03/20] test-opt: convert 12 more message specs to lightweight_spec_helper Convert the following message specs to use lightweight_spec_helper: - process_scale_message_spec.rb - process_show_message_spec.rb - process_update_message_spec.rb - purge_message_spec.rb - quotas_apps_message_spec.rb - quotas_routes_message_spec.rb - quotas_services_message_spec.rb - revisions_update_message_spec.rb - role_create_message_spec.rb - role_show_message_spec.rb - route_mappings_update_message_spec.rb - route_show_message_spec.rb Total converted: 28 message specs --- spec/unit/messages/process_scale_message_spec.rb | 2 +- spec/unit/messages/process_show_message_spec.rb | 3 ++- spec/unit/messages/process_update_message_spec.rb | 2 +- spec/unit/messages/purge_message_spec.rb | 2 +- spec/unit/messages/quotas_apps_message_spec.rb | 3 ++- spec/unit/messages/quotas_routes_message_spec.rb | 3 ++- spec/unit/messages/quotas_services_message_spec.rb | 3 ++- spec/unit/messages/revisions_update_message_spec.rb | 2 +- spec/unit/messages/role_create_message_spec.rb | 2 +- spec/unit/messages/role_show_message_spec.rb | 3 ++- spec/unit/messages/route_mappings_update_message_spec.rb | 2 +- spec/unit/messages/route_show_message_spec.rb | 2 +- 12 files changed, 17 insertions(+), 12 deletions(-) diff --git a/spec/unit/messages/process_scale_message_spec.rb b/spec/unit/messages/process_scale_message_spec.rb index 0a45eeda21..1fba19b83a 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 be040dbfb7..bde0a793d0 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 771aaf84ff..675d77f822 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 e113367195..32ba2336d9 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 fd3b69390f..ac04f20529 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 9007f7c775..31ebd163af 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 0e1d3bfd8a..5098f97e3e 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 bd40a1e8c8..cafbb9686c 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 6c900c239d..c434efb911 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 4494af49db..024bd7d98c 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 3308ee8404..a5cb2bf58f 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 3c7fa8378a..e01165d14a 100644 --- a/spec/unit/messages/route_show_message_spec.rb +++ b/spec/unit/messages/route_show_message_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/route_show_message' module VCAP::CloudController From 2af1bcc7e67300e96c879625b5f3dd2c1717e0ca Mon Sep 17 00:00:00 2001 From: johha Date: Mon, 2 Mar 2026 14:00:13 +0100 Subject: [PATCH 04/20] test-opt: convert 10 more message specs to lightweight_spec_helper Convert the following message specs to use lightweight_spec_helper: - service_credential_binding_create_message_spec.rb - sidecar_create_message_spec.rb - sidecar_update_message_spec.rb - space_delete_unmapped_routes_message_spec.rb - space_feature_update_message_spec.rb - stack_create_message_spec.rb - to_many_relationship_message_spec.rb - user_create_message_spec.rb - user_update_message_spec.rb - v2_v3_resource_translator_spec.rb Total converted: 38 message specs --- .../messages/service_credential_binding_create_message_spec.rb | 2 +- spec/unit/messages/sidecar_create_message_spec.rb | 2 +- spec/unit/messages/sidecar_update_message_spec.rb | 2 +- spec/unit/messages/space_delete_unmapped_routes_message_spec.rb | 2 +- spec/unit/messages/space_feature_update_message_spec.rb | 2 +- spec/unit/messages/stack_create_message_spec.rb | 2 +- spec/unit/messages/to_many_relationship_message_spec.rb | 2 +- spec/unit/messages/user_create_message_spec.rb | 2 +- spec/unit/messages/user_update_message_spec.rb | 2 +- spec/unit/messages/v2_v3_resource_translator_spec.rb | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/spec/unit/messages/service_credential_binding_create_message_spec.rb b/spec/unit/messages/service_credential_binding_create_message_spec.rb index 15a6db4afb..f6a407f212 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 56305a64a3..0e29e1b9a3 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 62120d914b..0ad8cce12e 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 cad0ac739e..913df7e71d 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 33df6a702e..a6684b6b61 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 aadf7b9191..788f99c0a1 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 072cb6f8f0..4ff36a0498 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 40427497f9..4cf5d80c51 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 0946a36fc7..e01dc90850 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 3df4039115..22a7dc87f1 100644 --- a/spec/unit/messages/v2_v3_resource_translator_spec.rb +++ b/spec/unit/messages/v2_v3_resource_translator_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/v2_v3_resource_translator' RSpec.describe VCAP::CloudController::V2V3ResourceTranslator do From 4fa15d0c2faef8295dc0c724a3041b424cfa2494 Mon Sep 17 00:00:00 2001 From: johha Date: Mon, 2 Mar 2026 14:30:24 +0100 Subject: [PATCH 05/20] Fix db_spec_helper loading issues - Add require for cloud_controller/diego/constants in instances_reporter.rb to ensure LRP_RUNNING constant is defined before use - Add require for 'oj' in db_spec_helper to ensure Oj is available before initializers run This fixes the broken db_spec_helper which was failing with: NameError: uninitialized constant VCAP::CloudController::Diego::LRP_RUNNING --- lib/cloud_controller/diego/reporters/instances_reporter.rb | 1 + spec/db_spec_helper.rb | 1 + 2 files changed, 2 insertions(+) diff --git a/lib/cloud_controller/diego/reporters/instances_reporter.rb b/lib/cloud_controller/diego/reporters/instances_reporter.rb index 7874e720d1..4bb39865a3 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 426fa2a8c8..624535ad53 100644 --- a/spec/db_spec_helper.rb +++ b/spec/db_spec_helper.rb @@ -4,6 +4,7 @@ require 'rspec/collection_matchers' require 'rails' + require 'oj' require 'support/bootstrap/spec_bootstrap' require 'support/database_isolation' require 'sequel_plugins/sequel_plugins' From 3061be8260c13271c9787d9a9819f981f17c06cd Mon Sep 17 00:00:00 2001 From: johha Date: Mon, 2 Mar 2026 14:38:06 +0100 Subject: [PATCH 06/20] Convert 4 fetcher specs to use db_spec_helper Add Sequel timezone configuration to db_spec_helper to fix timestamp comparison issues in tests. Converted specs: - app_fetcher_spec.rb - assign_current_droplet_fetcher_spec.rb - base_list_fetcher_spec.rb - build_list_fetcher_spec.rb These specs only need database models, not the full controller stack, so they can use the lighter db_spec_helper for faster load times. --- spec/db_spec_helper.rb | 3 +++ spec/unit/fetchers/app_fetcher_spec.rb | 2 +- spec/unit/fetchers/assign_current_droplet_fetcher_spec.rb | 2 +- spec/unit/fetchers/base_list_fetcher_spec.rb | 2 +- spec/unit/fetchers/build_list_fetcher_spec.rb | 2 +- 5 files changed, 7 insertions(+), 4 deletions(-) diff --git a/spec/db_spec_helper.rb b/spec/db_spec_helper.rb index 624535ad53..ec5ed14d61 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 1f7ca122e1..fde7568969 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 692d1514e0..a8fc16cbce 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 ef5a44dd6e..982416eeb8 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 8561b222d1..641abb3d1f 100644 --- a/spec/unit/fetchers/build_list_fetcher_spec.rb +++ b/spec/unit/fetchers/build_list_fetcher_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'db_spec_helper' require 'messages/builds_list_message' require 'fetchers/build_list_fetcher' From f9da616f0252eb15db38c4f8d49fcc3e379f3c27 Mon Sep 17 00:00:00 2001 From: johha Date: Mon, 2 Mar 2026 14:43:02 +0100 Subject: [PATCH 07/20] Convert 9 more fetcher specs to use db_spec_helper Converted specs: - droplet_fetcher_spec.rb - event_list_fetcher_spec.rb - organization_quota_list_fetcher_spec.rb - organization_user_roles_fetcher_spec.rb - package_fetcher_spec.rb - process_fetcher_spec.rb - route_destinations_list_fetcher_spec.rb - service_binding_list_fetcher_spec.rb - space_quota_list_fetcher_spec.rb These specs only need database models, not the full controller stack, reducing test load time from ~7s to ~2s per file. --- spec/unit/fetchers/droplet_fetcher_spec.rb | 2 +- spec/unit/fetchers/event_list_fetcher_spec.rb | 2 +- spec/unit/fetchers/organization_quota_list_fetcher_spec.rb | 2 +- spec/unit/fetchers/organization_user_roles_fetcher_spec.rb | 2 +- spec/unit/fetchers/package_fetcher_spec.rb | 2 +- spec/unit/fetchers/process_fetcher_spec.rb | 2 +- spec/unit/fetchers/route_destinations_list_fetcher_spec.rb | 2 +- spec/unit/fetchers/service_binding_list_fetcher_spec.rb | 2 +- spec/unit/fetchers/space_quota_list_fetcher_spec.rb | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/spec/unit/fetchers/droplet_fetcher_spec.rb b/spec/unit/fetchers/droplet_fetcher_spec.rb index a53478e3ee..7f0c419720 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 c599ae4c1f..08e41c2ec5 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 903b9b35ab..42283f07c2 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 246bf770d1..6291c13c45 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 7ce1dcdc5d..a230674560 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 a56751bb23..8eb0f43435 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 986f791d66..63f3658024 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 228e1c2e85..4a16b1e241 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 37bc07277e..7485274d62 100644 --- a/spec/unit/fetchers/space_quota_list_fetcher_spec.rb +++ b/spec/unit/fetchers/space_quota_list_fetcher_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'db_spec_helper' require 'fetchers/space_quota_list_fetcher' require 'messages/space_quotas_list_message' From 47b56eb6179bdf775fc88fdf07b23c2c68f18490 Mon Sep 17 00:00:00 2001 From: johha Date: Mon, 2 Mar 2026 14:56:49 +0100 Subject: [PATCH 08/20] Convert 11 presenter specs to use db_spec_helper Converted specs: - app_env_presenter_spec.rb - cache_key_presenter_spec.rb - domain_shared_orgs_presenter_spec.rb - organization_quota_presenter_spec.rb - relationship_presenter_spec.rb - route_destination_presenter_spec.rb - route_destinations_presenter_spec.rb - service_offering_presenter_spec.rb - space_quota_presenter_spec.rb - space_usage_summary_presenter_spec.rb - to_many_relationship_presenter_spec.rb These specs only need database models, not the full controller stack. --- spec/unit/presenters/v3/app_env_presenter_spec.rb | 2 +- spec/unit/presenters/v3/cache_key_presenter_spec.rb | 2 +- spec/unit/presenters/v3/domain_shared_orgs_presenter_spec.rb | 2 +- spec/unit/presenters/v3/organization_quota_presenter_spec.rb | 2 +- spec/unit/presenters/v3/relationship_presenter_spec.rb | 2 +- spec/unit/presenters/v3/route_destination_presenter_spec.rb | 2 +- spec/unit/presenters/v3/route_destinations_presenter_spec.rb | 2 +- spec/unit/presenters/v3/service_offering_presenter_spec.rb | 2 +- spec/unit/presenters/v3/space_quota_presenter_spec.rb | 2 +- spec/unit/presenters/v3/space_usage_summary_presenter_spec.rb | 2 +- spec/unit/presenters/v3/to_many_relationship_presenter_spec.rb | 2 +- 11 files changed, 11 insertions(+), 11 deletions(-) diff --git a/spec/unit/presenters/v3/app_env_presenter_spec.rb b/spec/unit/presenters/v3/app_env_presenter_spec.rb index 21cd0ebcdb..0c02f18986 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 1005b49555..8615fce76a 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 701c16f182..d118adbfaf 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 080fd4c5a8..c5fdad73f6 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 f4d0e64dc0..cb984c0a50 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 6484403fd4..ffc8f8089b 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 fea18da319..01b418e136 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 c99864c322..721ad5c03f 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 93e07b7a85..8123157c9d 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 2a35d82e1a..7b8a9b9597 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 5a3c017202..5b475e2b16 100644 --- a/spec/unit/presenters/v3/to_many_relationship_presenter_spec.rb +++ b/spec/unit/presenters/v3/to_many_relationship_presenter_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'db_spec_helper' require 'presenters/v3/to_many_relationship_presenter' module VCAP::CloudController::Presenters::V3 From 2f69bf22f8a0229b757e7a536373d32debe9c54c Mon Sep 17 00:00:00 2001 From: johha Date: Mon, 2 Mar 2026 15:04:35 +0100 Subject: [PATCH 09/20] Convert 4 decorator/repository specs to use db_spec_helper Converted specs: - embed_process_instances_decorator_spec.rb - field_service_offering_service_broker_decorator_spec.rb - field_service_plan_service_broker_decorator_spec.rb - event_types_spec.rb Added explicit requires for decorator classes since db_spec_helper doesn't autoload all application classes. --- spec/unit/decorators/embed_process_instances_decorator_spec.rb | 3 ++- .../field_service_offering_service_broker_decorator_spec.rb | 2 +- .../field_service_plan_service_broker_decorator_spec.rb | 2 +- spec/unit/repositories/event_types_spec.rb | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/spec/unit/decorators/embed_process_instances_decorator_spec.rb b/spec/unit/decorators/embed_process_instances_decorator_spec.rb index 3bd52c7256..d6ed356ae8 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 ce4097c64d..92fc28ea62 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 79e1ea060b..bd97deb66f 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 1dcf6997c1..19d6fecb11 100644 --- a/spec/unit/repositories/event_types_spec.rb +++ b/spec/unit/repositories/event_types_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'db_spec_helper' require 'repositories/event_types' module VCAP::CloudController From 870056e0af76c444d2583691fd7c228e7ede3666 Mon Sep 17 00:00:00 2001 From: johha Date: Mon, 2 Mar 2026 15:41:51 +0100 Subject: [PATCH 10/20] Split apps_spec.rb into 8 smaller files for better parallelization Original file: spec/request/apps_spec.rb (3,542 lines, 402 examples) Split into: - create_spec.rb (POST /v3/apps) - 451 lines - list_spec.rb (GET /v3/apps) - 939 lines - show_spec.rb (GET /v3/apps/:guid) - 494 lines - builds_and_ssh_spec.rb - 221 lines - delete_and_update_spec.rb - 328 lines - actions_spec.rb (start, stop, restart) - 663 lines - droplet_spec.rb (current_droplet endpoints) - 328 lines - environment_spec.rb (environment_variables, permissions) - 177 lines Shared test setup extracted to shared_context.rb This split enables better parallel test distribution since each file can run on a separate worker. --- spec/request/apps/actions_spec.rb | 664 ++++++++++++++ spec/request/apps/builds_and_ssh_spec.rb | 222 +++++ spec/request/apps/create_spec.rb | 451 ++++++++++ spec/request/apps/delete_and_update_spec.rb | 329 +++++++ spec/request/apps/droplet_spec.rb | 329 +++++++ spec/request/apps/environment_spec.rb | 178 ++++ spec/request/apps/list_spec.rb | 940 ++++++++++++++++++++ spec/request/apps/shared_context.rb | 10 + spec/request/apps/show_spec.rb | 495 +++++++++++ 9 files changed, 3618 insertions(+) create mode 100644 spec/request/apps/actions_spec.rb create mode 100644 spec/request/apps/builds_and_ssh_spec.rb create mode 100644 spec/request/apps/create_spec.rb create mode 100644 spec/request/apps/delete_and_update_spec.rb create mode 100644 spec/request/apps/droplet_spec.rb create mode 100644 spec/request/apps/environment_spec.rb create mode 100644 spec/request/apps/list_spec.rb create mode 100644 spec/request/apps/shared_context.rb create mode 100644 spec/request/apps/show_spec.rb diff --git a/spec/request/apps/actions_spec.rb b/spec/request/apps/actions_spec.rb new file mode 100644 index 0000000000..344dbb5f58 --- /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 0000000000..9fe458d90e --- /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 0000000000..fea0470cd5 --- /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 0000000000..1758648aee --- /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 0000000000..8983ff34be --- /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 0000000000..6d6527a802 --- /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 0000000000..dc20f00fe1 --- /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 0000000000..e2d74fc0a4 --- /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 0000000000..7c81205f9d --- /dev/null +++ b/spec/request/apps/show_spec.rb @@ -0,0 +1,495 @@ +require 'spec_helper' +require 'actions/process_create_from_app_droplet' +require 'request_spec_shared_examples' +require_relative 'shared_context' + +# Split from spec/request/apps_spec.rb for better test parallelization + +RSpec.describe 'Apps' do + include_context 'apps request spec' + + describe 'GET /v3/apps/:guid' do + let!(:buildpack) { VCAP::CloudController::Buildpack.make(name: 'bp-name') } + let!(:stack) { VCAP::CloudController::Stack.make(name: 'stack-name') } + let!(:app_model) do + VCAP::CloudController::AppModel.make( + :buildpack, + name: 'my_app', + guid: 'app1_guid', + space: space, + desired_state: 'STARTED', + environment_variables: { 'unicorn' => 'horn' } + ) + end + + before do + space.organization.add_user(user) + app_model.lifecycle_data.buildpacks = [buildpack.name] + app_model.lifecycle_data.stack = stack.name + app_model.lifecycle_data.save + app_model.add_process(VCAP::CloudController::ProcessModel.make(instances: 1)) + app_model.add_process(VCAP::CloudController::ProcessModel.make(instances: 2)) + end + + context 'when getting an app' do + let(:api_call) { ->(user_headers) { get "/v3/apps/#{app_model.guid}", nil, user_headers } } + + let(:app_model_response_object) do + { + guid: app_model.guid, + created_at: iso8601, + updated_at: iso8601, + name: app_model.name, + state: 'STARTED', + lifecycle: { + type: 'buildpack', + data: { buildpacks: [buildpack.name], stack: app_model.lifecycle_data.stack } + }, + relationships: { + space: { data: { guid: space.guid } }, + current_droplet: { data: { guid: app_model.droplet_guid } } + }, + metadata: { + labels: {}, + annotations: {} + }, + links: { + self: { href: "#{link_prefix}/v3/apps/app1_guid" }, + environment_variables: { href: "#{link_prefix}/v3/apps/app1_guid/environment_variables" }, + space: { href: "#{link_prefix}/v3/spaces/#{space.guid}" }, + processes: { href: "#{link_prefix}/v3/apps/app1_guid/processes" }, + packages: { href: "#{link_prefix}/v3/apps/app1_guid/packages" }, + current_droplet: { href: "#{link_prefix}/v3/apps/app1_guid/droplets/current" }, + droplets: { href: "#{link_prefix}/v3/apps/app1_guid/droplets" }, + tasks: { href: "#{link_prefix}/v3/apps/app1_guid/tasks" }, + start: { href: "#{link_prefix}/v3/apps/app1_guid/actions/start", method: 'POST' }, + stop: { href: "#{link_prefix}/v3/apps/app1_guid/actions/stop", method: 'POST' }, + clear_buildpack_cache: { href: "#{link_prefix}/v3/apps/app1_guid/actions/clear_buildpack_cache", method: 'POST' }, + revisions: { href: "#{link_prefix}/v3/apps/app1_guid/revisions" }, + deployed_revisions: { href: "#{link_prefix}/v3/apps/app1_guid/revisions/deployed" }, + features: { href: "#{link_prefix}/v3/apps/app1_guid/features" } + } + } + end + + let(:expected_codes_and_responses) do + h = Hash.new({ code: 200, response_object: app_model_response_object }.freeze) + h['org_auditor'] = { code: 404 } + h['org_billing_manager'] = { code: 404 } + h['no_role'] = { code: 404 } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + + context 'when the user has permission to view the app' do + before do + space.add_developer(user) + end + + it 'gets a specific app' do + get "/v3/apps/#{app_model.guid}", nil, user_header + expect(last_response.status).to eq(200) + + parsed_response = Oj.load(last_response.body) + expect(parsed_response).to be_a_response_like( + { + 'name' => 'my_app', + 'guid' => app_model.guid, + 'state' => 'STARTED', + 'created_at' => iso8601, + 'updated_at' => iso8601, + 'metadata' => { 'labels' => {}, 'annotations' => {} }, + 'lifecycle' => { + 'type' => 'buildpack', + 'data' => { + 'buildpacks' => ['bp-name'], + 'stack' => 'stack-name' + } + }, + 'relationships' => { + 'space' => { + 'data' => { + 'guid' => space.guid + } + }, + 'current_droplet' => { + 'data' => { + 'guid' => app_model.droplet_guid + } + } + }, + 'links' => { + 'self' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}" }, + 'processes' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/processes" }, + 'packages' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/packages" }, + 'environment_variables' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/environment_variables" }, + 'space' => { 'href' => "#{link_prefix}/v3/spaces/#{space.guid}" }, + 'current_droplet' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/droplets/current" }, + 'droplets' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/droplets" }, + 'tasks' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/tasks" }, + 'start' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/start", 'method' => 'POST' }, + 'stop' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/stop", 'method' => 'POST' }, + 'clear_buildpack_cache' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/clear_buildpack_cache", 'method' => 'POST' }, + 'revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/revisions" }, + 'deployed_revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/revisions/deployed" }, + 'features' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/features" } + } + } + ) + end + + it 'gets a specific app including space' do + get "/v3/apps/#{app_model.guid}?include=space", nil, user_header + expect(last_response.status).to eq(200) + + parsed_response = Oj.load(last_response.body) + expect(parsed_response).to be_a_response_like( + { + 'name' => 'my_app', + 'guid' => app_model.guid, + 'state' => 'STARTED', + 'created_at' => iso8601, + 'updated_at' => iso8601, + 'metadata' => { 'labels' => {}, 'annotations' => {} }, + 'lifecycle' => { + 'type' => 'buildpack', + 'data' => { + 'buildpacks' => ['bp-name'], + 'stack' => 'stack-name' + } + }, + 'relationships' => { + 'space' => { + 'data' => { + 'guid' => space.guid + } + }, + 'current_droplet' => { + 'data' => { + 'guid' => app_model.droplet_guid + } + } + }, + 'links' => { + 'self' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}" }, + 'processes' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/processes" }, + 'packages' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/packages" }, + 'environment_variables' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/environment_variables" }, + 'space' => { 'href' => "#{link_prefix}/v3/spaces/#{space.guid}" }, + 'current_droplet' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/droplets/current" }, + 'droplets' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/droplets" }, + 'tasks' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/tasks" }, + 'start' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/start", 'method' => 'POST' }, + 'stop' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/stop", 'method' => 'POST' }, + 'clear_buildpack_cache' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/actions/clear_buildpack_cache", 'method' => 'POST' }, + 'revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/revisions" }, + 'deployed_revisions' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/revisions/deployed" }, + 'features' => { 'href' => "#{link_prefix}/v3/apps/#{app_model.guid}/features" } + }, + 'included' => { + 'spaces' => [{ + 'guid' => space.guid, + 'created_at' => iso8601, + 'updated_at' => iso8601, + 'name' => space.name, + 'relationships' => { + 'organization' => { + 'data' => { + 'guid' => space.organization.guid + } + }, + 'quota' => { + 'data' => nil + } + }, + 'metadata' => { + 'labels' => {}, + 'annotations' => {} + }, + 'links' => { + 'self' => { + 'href' => "#{link_prefix}/v3/spaces/#{space.guid}" + }, + 'organization' => { + 'href' => "#{link_prefix}/v3/organizations/#{space.organization.guid}" + }, + 'features' => { 'href' => %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}/features} }, + 'apply_manifest' => { + 'href' => "#{link_prefix}/v3/spaces/#{space.guid}/actions/apply_manifest", + 'method' => 'POST' + } + } + }] + } + } + ) + end + + it 'gets a specific app including space and org' do + get "/v3/apps/#{app_model.guid}?include=space.organization", nil, user_header + expect(last_response.status).to eq(200) + + parsed_response = Oj.load(last_response.body) + spaces = parsed_response['included']['spaces'] + orgs = parsed_response['included']['organizations'] + + expect(spaces).to be_present + expect(orgs[0]).to be_a_response_like( + { + 'guid' => org.guid, + 'created_at' => iso8601, + 'updated_at' => iso8601, + 'name' => org.name, + 'metadata' => { + 'labels' => {}, + 'annotations' => {} + }, + 'suspended' => false, + 'links' => { + 'self' => { + 'href' => "#{link_prefix}/v3/organizations/#{org.guid}" + }, + 'default_domain' => { + 'href' => "#{link_prefix}/v3/organizations/#{org.guid}/domains/default" + }, + 'domains' => { + 'href' => "#{link_prefix}/v3/organizations/#{org.guid}/domains" + }, + 'quota' => { + 'href' => "#{link_prefix}/v3/organization_quotas/#{org.quota_definition.guid}" + } + }, + 'relationships' => { 'quota' => { 'data' => { 'guid' => org.quota_definition.guid } } } + } + ) + end + end + end + + describe 'GET /v3/apps/:guid/env' do + before do + space.organization.add_user(user) + end + + context 'when getting an apps environment variables' do + let(:api_call) { ->(user_headers) { get "/v3/apps/#{app_model.guid}/env", nil, user_headers } } + let!(:app_model) do + VCAP::CloudController::AppModel.make( + :buildpack, + name: 'my_app', + guid: 'app1_guid', + space: space, + environment_variables: { 'unicorn' => 'horn' } + ) + end + + let(:app_model_response_object) do + { + environment_variables: app_model.environment_variables, + staging_env_json: {}, + running_env_json: {}, + system_env_json: { VCAP_SERVICES: {} }, + application_env_json: anything + } + end + let(:app_model_empty_system_env_response_object) do + { + environment_variables: app_model.environment_variables, + staging_env_json: {}, + running_env_json: {}, + system_env_json: { + redacted_message: '[PRIVATE DATA HIDDEN]' + }, + application_env_json: anything + } + end + + let(:expected_codes_and_responses) do + h = Hash.new({ code: 200, response_object: app_model_response_object }.freeze) + h['space_supporter'] = { code: 200, response_object: app_model_empty_system_env_response_object } + h['global_auditor'] = h['org_manager'] = h['space_manager'] = h['space_auditor'] = { code: 403 } + h['org_auditor'] = h['org_billing_manager'] = h['no_role'] = { code: 404 } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + + context 'when k8s service bindings are enabled' do + let(:app_model_response_object) do + r = super() + r[:system_env_json] = { SERVICE_BINDING_ROOT: '/etc/cf-service-bindings' } + r + end + + before do + app_model.update(service_binding_k8s_enabled: true) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + + context 'when file-based VCAP service bindings are enabled' do + let(:app_model_response_object) do + r = super() + r[:system_env_json] = { VCAP_SERVICES_FILE_PATH: '/etc/cf-service-bindings/vcap_services' } + r + end + + before do + app_model.update(file_based_vcap_services_enabled: true) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + end + + context 'when VCAP_SERVICES contains potentially sensitive information' do + before do + group = VCAP::CloudController::EnvironmentVariableGroup.staging + group.environment_json = { STAGING_ENV: 'staging_value' } + group.save + + group = VCAP::CloudController::EnvironmentVariableGroup.running + group.environment_json = { RUNNING_ENV: 'running_value' } + group.save + end + + let(:api_call) { ->(user_headers) { get "/v3/apps/#{app_model.guid}/env", nil, user_headers } } + let(:app_model) do + VCAP::CloudController::AppModel.make( + name: 'my_app', + space: space, + environment_variables: { 'unicorn' => 'horn' } + ) + end + let(:service_instance) do + VCAP::CloudController::ManagedServiceInstance.make( + space: space, + name: 'si-name', + tags: ['50% off'] + ) + end + let(:service_binding) do + VCAP::CloudController::ServiceBinding.make( + service_instance: service_instance, + app: app_model, + syslog_drain_url: 'https://syslog.example.com/drain', + credentials: { password: 'top-secret' } + ) + end + let(:expected_response) do + { + 'staging_env_json' => { + 'STAGING_ENV' => 'staging_value' + }, + 'running_env_json' => { + 'RUNNING_ENV' => 'running_value' + }, + 'environment_variables' => { + 'unicorn' => 'horn' + }, + 'system_env_json' => { + 'VCAP_SERVICES' => { + service_instance.service.label => [ + { + 'name' => 'si-name', + 'instance_guid' => service_instance.guid, + 'instance_name' => 'si-name', + 'binding_guid' => service_binding.guid, + 'binding_name' => nil, + 'credentials' => { 'password' => 'top-secret' }, + 'syslog_drain_url' => 'https://syslog.example.com/drain', + 'volume_mounts' => [], + 'label' => service_instance.service.label, + 'provider' => nil, + 'plan' => service_instance.service_plan.name, + 'tags' => ['50% off'] + } + ] + } + }, + 'application_env_json' => { + 'VCAP_APPLICATION' => { + 'cf_api' => "#{TestConfig.config[:external_protocol]}://#{TestConfig.config[:external_domain]}", + 'limits' => { + 'fds' => 16_384 + }, + 'application_name' => 'my_app', + 'application_uris' => [], + 'name' => 'my_app', + 'organization_id' => space.organization.guid, + 'organization_name' => space.organization.name, + 'space_id' => space.guid, + 'space_name' => space.name, + 'uris' => [], + 'users' => nil, + 'application_id' => app_model.guid + } + } + } + end + + let(:expected_response_system_env_redacted) do + { + 'staging_env_json' => { + 'STAGING_ENV' => 'staging_value' + }, + 'running_env_json' => { + 'RUNNING_ENV' => 'running_value' + }, + 'environment_variables' => { + 'unicorn' => 'horn' + }, + 'system_env_json' => { + 'redacted_message' => '[PRIVATE DATA HIDDEN]' + }, + 'application_env_json' => { + 'VCAP_APPLICATION' => { + 'cf_api' => "#{TestConfig.config[:external_protocol]}://#{TestConfig.config[:external_domain]}", + 'limits' => { + 'fds' => 16_384 + }, + 'application_name' => 'my_app', + 'application_uris' => [], + 'name' => 'my_app', + 'organization_id' => space.organization.guid, + 'organization_name' => space.organization.name, + 'space_id' => space.guid, + 'space_name' => space.name, + 'uris' => [], + 'users' => nil, + 'application_id' => app_model.guid + } + } + } + end + + let(:expected_codes_and_responses) do + h = Hash.new({ code: 403 }.freeze) + h['admin'] = h['admin_read_only'] = h['space_developer'] = { code: 200, response_object: expected_response } + h['space_supporter'] = { code: 200, response_object: expected_response_system_env_redacted } + h['org_auditor'] = h['org_billing_manager'] = h['no_role'] = { code: 404 } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + + context 'when the space_developer_env_var_visibility feature flag is disabled' do + before do + VCAP::CloudController::FeatureFlag.make(name: 'space_developer_env_var_visibility', enabled: false, error_message: nil) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS do + let(:expected_codes_and_responses) do + h = Hash.new({ code: 403 }.freeze) + h['admin'] = h['admin_read_only'] = { code: 200, response_object: expected_response } + h['org_auditor'] = h['org_billing_manager'] = h['no_role'] = { code: 404 } + h + end + end + end + end + end +end From 67690e95a8de526cac6e0d6e1386687aa55517a6 Mon Sep 17 00:00:00 2001 From: johha Date: Mon, 2 Mar 2026 15:45:13 +0100 Subject: [PATCH 11/20] Split routes_spec.rb into 6 smaller files for better parallelization Original file: spec/request/routes_spec.rb (3,748 lines, 401 examples) **Deleted original file after splitting** Split into: - list_spec.rb (GET /v3/routes) - 937 lines - show_spec.rb (GET /v3/routes/:guid) - 171 lines - create_spec.rb (POST /v3/routes) - 1289 lines - update_and_delete_spec.rb - 277 lines - sharing_spec.rb (shared_spaces relationships) - 917 lines - apps_routes_spec.rb (GET /v3/apps/:app_guid/routes) - 173 lines Shared test setup extracted to shared_context.rb This split enables better parallel test distribution. --- spec/request/apps_spec.rb | 3542 ---------------- spec/request/routes/apps_routes_spec.rb | 173 + spec/request/routes/create_spec.rb | 1290 ++++++ spec/request/routes/list_spec.rb | 938 +++++ spec/request/routes/shared_context.rb | 31 + spec/request/routes/sharing_spec.rb | 918 ++++ spec/request/routes/show_spec.rb | 172 + spec/request/routes/update_and_delete_spec.rb | 278 ++ spec/request/routes_spec.rb | 3748 ----------------- 9 files changed, 3800 insertions(+), 7290 deletions(-) delete mode 100644 spec/request/apps_spec.rb create mode 100644 spec/request/routes/apps_routes_spec.rb create mode 100644 spec/request/routes/create_spec.rb create mode 100644 spec/request/routes/list_spec.rb create mode 100644 spec/request/routes/shared_context.rb create mode 100644 spec/request/routes/sharing_spec.rb create mode 100644 spec/request/routes/show_spec.rb create mode 100644 spec/request/routes/update_and_delete_spec.rb delete mode 100644 spec/request/routes_spec.rb diff --git a/spec/request/apps_spec.rb b/spec/request/apps_spec.rb deleted file mode 100644 index 64fcef98a7..0000000000 --- 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 0000000000..8357d59037 --- /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 0000000000..4d6d709d4c --- /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 0000000000..4a987141e3 --- /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 0000000000..c634e429f6 --- /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 0000000000..00015c496d --- /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 0000000000..d566de16b3 --- /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 0000000000..79c60c5ab6 --- /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 2c2fe30cd8..0000000000 --- a/spec/request/routes_spec.rb +++ /dev/null @@ -1,3748 +0,0 @@ -require 'spec_helper' -require 'request_spec_shared_examples' -require 'presenters/v3/space_presenter' -require 'presenters/v3/organization_presenter' - -RSpec.describe 'Routes Request' do - let(:user) { VCAP::CloudController::User.make } - let(:admin_header) { admin_headers_for(user) } - let!(:org) { VCAP::CloudController::Organization.make(created_at: 1.hour.ago) } - let!(:space) { VCAP::CloudController::Space.make(name: 'a-space', created_at: 1.hour.ago, organization: org) } - - let(:space_json_generator) do - lambda { |s| - presented_space = VCAP::CloudController::Presenters::V3::SpacePresenter.new(s).to_hash - presented_space[:created_at] = iso8601 - presented_space[:updated_at] = iso8601 - presented_space - } - end - - let(:org_json_generator) do - lambda { |o| - presented_space = VCAP::CloudController::Presenters::V3::OrganizationPresenter.new(o).to_hash - presented_space[:created_at] = iso8601 - presented_space[:updated_at] = iso8601 - presented_space - } - end - - before do - TestConfig.override(kubernetes: {}) - end - - describe 'GET /v3/routes' do - let(:other_space) { VCAP::CloudController::Space.make(name: 'b-space') } - let(:app_model) { VCAP::CloudController::AppModel.make(space:) } - let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } - let!(:route_in_org) do - VCAP::CloudController::Route.make(space: space, domain: domain, host: 'host-1', path: '/path1', guid: 'route-in-org-guid') - end - let!(:route_in_other_org) do - VCAP::CloudController::Route.make(space: other_space, host: 'host-2', path: '/path2', guid: 'route-in-other-org-guid') - end - let!(:route_in_org_dest_web) { VCAP::CloudController::RouteMappingModel.make(app: app_model, route: route_in_org, process_type: 'web') } - let!(:route_in_org_dest_worker) { VCAP::CloudController::RouteMappingModel.make(app: app_model, route: route_in_org, process_type: 'worker') } - let(:api_call) { ->(user_headers) { get '/v3/routes', nil, user_headers } } - let(:route_in_org_json) do - { - guid: route_in_org.guid, - protocol: route_in_org.domain.protocols[0], - host: route_in_org.host, - path: route_in_org.path, - port: nil, - url: "#{route_in_org.host}.#{route_in_org.domain.name}#{route_in_org.path}", - created_at: iso8601, - updated_at: iso8601, - destinations: contain_exactly({ - guid: route_in_org_dest_web.guid, - app: { - guid: app_model.guid, - process: { - type: route_in_org_dest_web.process_type - } - }, - weight: route_in_org_dest_web.weight, - port: route_in_org_dest_web.presented_port, - protocol: 'http1', - created_at: iso8601, - updated_at: iso8601 - }, { - guid: route_in_org_dest_worker.guid, - app: { - guid: app_model.guid, - process: { - type: route_in_org_dest_worker.process_type - } - }, - weight: route_in_org_dest_worker.weight, - port: route_in_org_dest_worker.presented_port, - protocol: 'http1', - created_at: iso8601, - updated_at: iso8601 - }), - relationships: { - space: { - data: { guid: route_in_org.space.guid } - }, - domain: { - data: { guid: route_in_org.domain.guid } - } - }, - metadata: { - labels: {}, - annotations: {} - }, - options: {}, - links: { - self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{route_in_org.guid}} }, - space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{route_in_org.space.guid}} }, - destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{route_in_org.guid}/destinations} }, - domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{route_in_org.domain.guid}} } - } - } - end - - let(:route_in_other_org_json) do - { - guid: route_in_other_org.guid, - protocol: route_in_other_org.domain.protocols[0], - host: route_in_other_org.host, - path: route_in_other_org.path, - port: nil, - url: "#{route_in_other_org.host}.#{route_in_other_org.domain.name}#{route_in_other_org.path}", - created_at: iso8601, - updated_at: iso8601, - destinations: [], - relationships: { - space: { - data: { guid: route_in_other_org.space.guid } - }, - domain: { - data: { guid: route_in_other_org.domain.guid } - } - }, - metadata: { - labels: {}, - annotations: {} - }, - options: {}, - links: { - self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{route_in_other_org.guid}} }, - space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{route_in_other_org.space.guid}} }, - destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{route_in_other_org.guid}/destinations} }, - domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{route_in_other_org.domain.guid}} } - } - } - end - - it_behaves_like 'list_endpoint_with_common_filters' do - let(:resource_klass) { VCAP::CloudController::Route } - let(:api_call) do - ->(headers, filters) { get "/v3/routes?#{filters}", nil, headers } - end - let(:headers) { admin_headers } - end - - describe 'query list parameters' do - it_behaves_like 'list query endpoint' do - let(:request) { 'v3/routes' } - let(:message) { VCAP::CloudController::RoutesListMessage } - let(:user_header) { admin_header } - - let(:params) do - { - page: '2', - per_page: '10', - order_by: 'updated_at', - space_guids: %w[foo bar], - service_instance_guids: %w[baz qux], - organization_guids: %w[foo bar], - domain_guids: %w[foo bar], - app_guids: %w[foo bar], - guids: %w[foo bar], - paths: %w[foo bar], - hosts: 'foo', - ports: 636, - include: 'domain', - label_selector: 'foo,bar', - created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", - updated_ats: { gt: Time.now.utc.iso8601 } - } - end - end - end - - context 'when the user is a member in the routes org' do - let(:expected_codes_and_responses) do - h = Hash.new( - { code: 200, - response_objects: [route_in_org_json] }.freeze - ) - - h['admin'] = { code: 200, response_objects: [route_in_org_json, route_in_other_org_json] } - h['admin_read_only'] = { code: 200, response_objects: [route_in_org_json, route_in_other_org_json] } - h['global_auditor'] = { code: 200, response_objects: [route_in_org_json, route_in_other_org_json] } - - h['org_billing_manager'] = { code: 200, response_objects: [] } - h['no_role'] = { code: 200, response_objects: [] } - h - end - - it_behaves_like 'permissions for list endpoint', ALL_PERMISSIONS - end - - describe 'includes' do - context 'when including domains' do - let(:domain1) { VCAP::CloudController::SharedDomain.make(name: 'first-domain.example.com') } - let(:domain1_json) do - { - guid: domain1.guid, - created_at: iso8601, - updated_at: iso8601, - name: domain1.name, - internal: false, - router_group: nil, - supported_protocols: ['http'], - metadata: { - labels: {}, - annotations: {} - }, - relationships: { - organization: { - data: nil - }, - shared_organizations: { - data: [] - } - }, - links: { - self: { href: "#{link_prefix}/v3/domains/#{domain1.guid}" }, - route_reservations: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain1.guid}/route_reservations} } - } - } - end - - let(:domain2) { VCAP::CloudController::SharedDomain.make(name: 'second-domain.example.com') } - let(:domain2_json) do - { - guid: domain2.guid, - created_at: iso8601, - updated_at: iso8601, - name: domain2.name, - internal: false, - router_group: nil, - supported_protocols: ['http'], - metadata: { - labels: {}, - annotations: {} - }, - relationships: { - organization: { - data: nil - }, - shared_organizations: { - data: [] - } - }, - links: { - self: { href: "#{link_prefix}/v3/domains/#{domain1.guid}" }, - route_reservations: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain1.guid}/route_reservations} } - } - } - end - - let(:domain2) { VCAP::CloudController::SharedDomain.make(name: 'second-domain.example.com') } - let(:domain2_json) do - { - guid: domain2.guid, - created_at: iso8601, - updated_at: iso8601, - name: domain2.name, - internal: false, - router_group: nil, - supported_protocols: ['http'], - metadata: { - labels: {}, - annotations: {} - }, - relationships: { - organization: { - data: nil - }, - shared_organizations: { - data: [] - } - }, - links: { - self: { href: "#{link_prefix}/v3/domains/#{domain2.guid}" }, - route_reservations: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain2.guid}/route_reservations} } - } - } - end - - let!(:route1_domain1) do - VCAP::CloudController::Route.make(space: space, host: 'route1', domain: domain1, path: '/path1', guid: 'route1-guid') - end - let(:route1_domain1_json) do - { - guid: route1_domain1.guid, - protocol: route1_domain1.domain.protocols[0], - created_at: iso8601, - updated_at: iso8601, - host: route1_domain1.host, - path: route1_domain1.path, - port: nil, - url: "#{route1_domain1.host}.#{domain1.name}#{route1_domain1.path}", - destinations: [], - metadata: { - labels: {}, - annotations: {} - }, - relationships: { - space: { - data: { - guid: space.guid - } - }, - domain: { - data: { - guid: domain1.guid - } - } - }, - options: {}, - links: { - self: { href: "http://api2.vcap.me/v3/routes/#{route1_domain1.guid}" }, - space: { href: "http://api2.vcap.me/v3/spaces/#{space.guid}" }, - destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{route1_domain1.guid}/destinations} }, - domain: { href: "http://api2.vcap.me/v3/domains/#{domain1.guid}" } - } - } - end - - let!(:route_in_org) do - VCAP::CloudController::Route.make(space: space, domain: domain1, host: 'host-1', path: '/path1', guid: 'route-in-org-guid') - end - let!(:route_in_other_org) do - VCAP::CloudController::Route.make(space: other_space, domain: domain2, host: 'host-2', path: '/path2', guid: 'route-in-other-org-guid') - end - - it 'includes the unique domains for the routes' do - get '/v3/routes?include=domain', nil, admin_header - expect(last_response).to have_status_code(200) - expect({ - resources: parsed_response['resources'], - included: parsed_response['included'] - }).to match_json_response({ - resources: [route_in_org_json, route_in_other_org_json, route1_domain1_json], - included: { 'domains' => [domain1_json, domain2_json] } - }) - end - end - - context 'when including spaces and orgs' do - it 'includes the unique spaces and organizations for the routes' do - get '/v3/routes?include=space,space.organization', nil, admin_header - expect(last_response).to have_status_code(200) - expect({ - resources: parsed_response['resources'], - included: parsed_response['included'] - }).to match_json_response({ - resources: [route_in_org_json, route_in_other_org_json], - included: { - 'spaces' => [ - space_json_generator.call(space), - space_json_generator.call(other_space) - ], - 'organizations' => [ - org_json_generator.call(org), - org_json_generator.call(other_space.organization) - ] - } - }) - end - end - - context 'when including spaces' do - it 'eagerly loads spaces to efficiently access space_guid' do - expect(VCAP::CloudController::IncludeSpaceDecorator).to receive(:decorate) do |_, resources| - expect(resources).not_to be_empty - resources.each { |r| expect(r.associations).to include(:space) } - end - - get '/v3/routes?include=space', nil, admin_header - expect(last_response).to have_status_code(200) - end - end - - context 'when including orgs' do - it 'eagerly loads spaces to efficiently access space.organization_id' do - expect(VCAP::CloudController::IncludeOrganizationDecorator).to receive(:decorate) do |_, resources| - expect(resources).not_to be_empty - resources.each { |r| expect(r.associations).to include(:space) } - end - - get '/v3/routes?include=space.organization', nil, admin_header - expect(last_response).to have_status_code(200) - end - end - end - - describe 'filters' do - let!(:route_without_host_and_with_path) do - VCAP::CloudController::Route.make(space: space, host: '', domain: domain, path: '/path1', guid: 'route-without-host') - end - let!(:route_without_host_and_with_path2) do - VCAP::CloudController::Route.make(space: space, host: '', domain: domain, path: '/path2', guid: 'route-without-host2') - end - let(:route_without_host_and_with_path_json) do - { - guid: 'route-without-host', - protocol: domain.protocols[0], - created_at: iso8601, - updated_at: iso8601, - destinations: [], - host: '', - path: '/path1', - port: nil, - url: "#{domain.name}/path1", - metadata: { - labels: {}, - annotations: {} - }, - relationships: { - space: { - data: { - guid: space.guid - } - }, - domain: { - data: { - guid: domain.guid - } - } - }, - options: {}, - links: { - self: { href: 'http://api2.vcap.me/v3/routes/route-without-host' }, - space: { href: "http://api2.vcap.me/v3/spaces/#{space.guid}" }, - destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/route-without-host/destinations} }, - domain: { href: "http://api2.vcap.me/v3/domains/#{domain.guid}" } - } - } - end - let(:route_without_host_and_with_path2_json) do - { - guid: 'route-without-host2', - protocol: domain.protocols[0], - created_at: iso8601, - updated_at: iso8601, - destinations: [], - host: '', - path: '/path2', - port: nil, - url: "#{domain.name}/path2", - metadata: { - labels: {}, - annotations: {} - }, - relationships: { - space: { - data: { - guid: space.guid - } - }, - domain: { - data: { - guid: domain.guid - } - } - }, - options: {}, - links: { - self: { href: 'http://api2.vcap.me/v3/routes/route-without-host2' }, - space: { href: "http://api2.vcap.me/v3/spaces/#{space.guid}" }, - destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/route-without-host2/destinations} }, - domain: { href: "http://api2.vcap.me/v3/domains/#{domain.guid}" } - } - } - end - let!(:route_without_path_and_with_host) do - VCAP::CloudController::Route.make(space: space, host: 'host-1', domain: domain, path: '', guid: 'route-without-path') - end - let(:route_without_path_and_with_host_json) do - { - guid: 'route-without-path', - protocol: domain.protocols[0], - created_at: iso8601, - updated_at: iso8601, - destinations: [], - host: 'host-1', - path: '', - port: nil, - url: "host-1.#{domain.name}", - metadata: { - labels: {}, - annotations: {} - }, - relationships: { - space: { - data: { - guid: space.guid - } - }, - domain: { - data: { - guid: domain.guid - } - } - }, - options: {}, - links: { - self: { href: 'http://api2.vcap.me/v3/routes/route-without-path' }, - space: { href: "http://api2.vcap.me/v3/spaces/#{space.guid}" }, - destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/route-without-path/destinations} }, - domain: { href: "http://api2.vcap.me/v3/domains/#{domain.guid}" } - } - } - end - - context 'hosts filter' do - it 'returns routes filtered by host' do - get '/v3/routes?hosts=host-1', nil, admin_header - expect(last_response).to have_status_code(200) - expect({ - resources: parsed_response['resources'] - }).to match_json_response({ - resources: [route_in_org_json, route_without_path_and_with_host_json] - }) - end - - it 'returns route with no host if one exists when filtering by empty host' do - get '/v3/routes?hosts=', nil, admin_header - expect(last_response).to have_status_code(200) - expect({ - resources: parsed_response['resources'] - }).to match_json_response({ - resources: [route_without_host_and_with_path_json, route_without_host_and_with_path2_json] - }) - end - end - - context 'paths filter' do - it 'returns routes filtered by path' do - get '/v3/routes?paths=%2Fpath1', nil, admin_header - expect(last_response).to have_status_code(200) - expect({ - resources: parsed_response['resources'] - }).to match_json_response({ - resources: [route_in_org_json, route_without_host_and_with_path_json] - }) - end - - it 'returns route with no path when filtering by empty path' do - get '/v3/routes?paths=', nil, admin_header - expect(last_response).to have_status_code(200) - expect({ - resources: parsed_response['resources'] - }).to match_json_response({ - resources: [route_without_path_and_with_host_json] - }) - end - end - - context 'hosts and paths filter' do - it 'returns routes with no host and the provided path when host is empty' do - get '/v3/routes?paths=%2Fpath1&hosts=', nil, admin_header - expect(last_response).to have_status_code(200) - expect({ - resources: parsed_response['resources'] - }).to match_json_response({ - resources: [route_without_host_and_with_path_json] - }) - end - end - - context 'organization_guids filter' do - it 'returns routes filtered by organization_guid' do - get "/v3/routes?organization_guids=#{other_space.organization.guid}", nil, admin_header - expect(last_response).to have_status_code(200) - expect({ - resources: parsed_response['resources'] - }).to match_json_response({ - resources: [route_in_other_org_json] - }) - end - end - - context 'space_guids filter' do - it 'returns routes filtered by space_guid' do - get "/v3/routes?space_guids=#{other_space.guid}", nil, admin_header - expect(last_response).to have_status_code(200) - expect({ - resources: parsed_response['resources'] - }).to match_json_response({ - resources: [route_in_other_org_json] - }) - end - end - - context 'domain_guids filter' do - it 'returns routes filtered by domain_guid' do - get "/v3/routes?domain_guids=#{route_in_other_org.domain.guid}", nil, admin_header - expect(last_response).to have_status_code(200) - expect({ - resources: parsed_response['resources'] - }).to match_json_response({ - resources: [route_in_other_org_json] - }) - end - end - - context 'app_guids filter' do - it 'returns routes filtered by app_guid' do - get "/v3/routes?app_guids=#{app_model.guid}", nil, admin_header - expect(last_response).to have_status_code(200) - expect(parsed_response['resources'].size).to eq(1) - expect(parsed_response['resources'].first['destinations'].size).to eq(2) - expect( - parsed_response['resources'].first['destinations'].map { |destination| destination['app']['guid'] }.uniq - ).to eq([app_model.guid]) - end - end - - context 'ports filter' do - # Don't even think of converting the following hash to symbols ('type' => 'tcp' NOT type: 'tcp'), and you need to set the GUID - let(:router_group) { VCAP::CloudController::RoutingApi::RouterGroup.new({ 'type' => 'tcp', 'reservable_ports' => '7777,8888,9999', 'guid' => 'some-guid' }) } - let(:routing_api_client) { instance_double(VCAP::CloudController::RoutingApi::Client) } - - before do - allow_any_instance_of(CloudController::DependencyLocator).to receive(:routing_api_client).and_return(routing_api_client) - allow(routing_api_client).to receive_messages(enabled?: true, router_group: router_group) - end - - context 'when there are multiple TCP routes with different ports' do - # The following `let`s depend on the above `before do` - let(:domain_tcp) { VCAP::CloudController::SharedDomain.make(router_group_guid: router_group.guid, name: 'my.domain') } - let!(:route_with_ports_0) do - VCAP::CloudController::Route.make(host: '', space: space, domain: domain_tcp, guid: 'route-with-port-0', port: 7777) - end - let!(:route_with_ports_1) do - VCAP::CloudController::Route.make(host: '', space: space, domain: domain_tcp, guid: 'route-with-port-1', port: 8888) - end - let!(:route_with_ports_2) do - VCAP::CloudController::Route.make(host: '', space: space, domain: domain_tcp, guid: 'route-with-port-2', port: 9999) - end - - it 'returns routes filtered by ports' do - get '/v3/routes?ports=7777,8888', nil, admin_header - expect(last_response).to have_status_code(200) - expect(parsed_response['resources'].size).to eq(2) - expect(parsed_response['resources'].pluck('port')).to contain_exactly(route_with_ports_0.port, route_with_ports_1.port) - end - end - end - - context 'service instance guids filter' do - let(:service_instance_one) do - VCAP::CloudController::ManagedServiceInstance.make(:routing, space: space, name: 'si-name-1') - end - let(:service_instance_two) do - VCAP::CloudController::ManagedServiceInstance.make(:routing, space: space, name: 'si-name-2') - end - - let!(:route_with_service_instance_one) do - VCAP::CloudController::Route.make(space: space, host: 'host-with-service-instance-one', domain: domain, path: '/path1', guid: 'route-with-service-instance-one') - end - let!(:route_with_service_instance_two) do - VCAP::CloudController::Route.make(space: space, host: 'host-with-service-instance-two', domain: domain, path: '/path2', guid: 'route-with-service-instance-two') - end - - let!(:route_mapping_one) { VCAP::CloudController::RouteBinding.make(route: route_with_service_instance_one, service_instance: service_instance_one) } - let!(:route_mapping_two) { VCAP::CloudController::RouteBinding.make(route: route_with_service_instance_two, service_instance: service_instance_two) } - - it 'returns routes filtered by service instance guid' do - get "/v3/routes?service_instance_guids=#{service_instance_one.guid},#{service_instance_two.guid}", nil, admin_header - expect(last_response).to have_status_code(200) - expect(parsed_response['resources'].size).to eq(2) - expect(parsed_response['resources'].pluck('guid')).to contain_exactly('route-with-service-instance-one', 'route-with-service-instance-two') - end - end - end - - describe 'labels' do - let!(:domain1) { VCAP::CloudController::PrivateDomain.make(name: 'dom1.com', owning_organization: org) } - let!(:route1) { VCAP::CloudController::Route.make(space: space, domain: domain1, host: 'hall', path: '/oates', guid: 'guid-1') } - let!(:route1_label) { VCAP::CloudController::RouteLabelModel.make(resource_guid: route1.guid, key_name: 'animal', value: 'dog') } - - let!(:domain2) { VCAP::CloudController::PrivateDomain.make(name: 'dom2.com', owning_organization: org) } - let!(:route2) { VCAP::CloudController::Route.make(space: space, domain: domain2, guid: 'guid-2') } - let!(:route2_label) { VCAP::CloudController::RouteLabelModel.make(resource_guid: route2.guid, key_name: 'animal', value: 'cow') } - let!(:route2__exclusive_label) { VCAP::CloudController::RouteLabelModel.make(resource_guid: route2.guid, key_name: 'santa', value: 'claus') } - - describe 'label_selectors' do - it 'returns a 200 and the filtered routes for "in" label selector' do - get '/v3/routes?label_selector=animal in (dog)', nil, admin_header - - expect(last_response).to have_status_code(200), last_response.body - parsed_response = Oj.load(last_response.body) - - expected_pagination = { - 'total_results' => 1, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal+in+%28dog%29&page=1&per_page=50" }, - 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal+in+%28dog%29&page=1&per_page=50" }, - 'next' => nil, - 'previous' => nil - } - - expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route1.guid) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - - it 'returns a 200 and the filtered routes for "in" label selector with space guids' do - get "/v3/routes?label_selector=animal in (dog)&space_guids=#{space.guid}", nil, admin_header - - expect(last_response).to have_status_code(200) - parsed_response = Oj.load(last_response.body) - - expected_pagination = { - 'total_results' => 1, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal+in+%28dog%29&page=1&per_page=50&space_guids=#{space.guid}" }, - 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal+in+%28dog%29&page=1&per_page=50&space_guids=#{space.guid}" }, - 'next' => nil, - 'previous' => nil - } - - expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route1.guid) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - - it 'returns a 200 and the filtered routes for "in" label selector with org filters' do - get "/v3/routes?label_selector=animal in (dog)&organization_guids=#{org.guid}", nil, admin_header - - expect(last_response).to have_status_code(200), last_response.body - parsed_response = Oj.load(last_response.body) - - expected_pagination = { - 'total_results' => 1, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal+in+%28dog%29&organization_guids=#{org.guid}&page=1&per_page=50" }, - 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal+in+%28dog%29&organization_guids=#{org.guid}&page=1&per_page=50" }, - 'next' => nil, - 'previous' => nil - } - - expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route1.guid) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - - it 'returns a 200 and the filtered routes for "in" label selector with domain filters' do - get "/v3/routes?label_selector=animal in (dog)&domain_guids=#{domain1.guid}", nil, admin_header - - expect(last_response).to have_status_code(200), last_response.body - parsed_response = Oj.load(last_response.body) - - expected_pagination = { - 'total_results' => 1, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/routes?domain_guids=#{domain1.guid}&label_selector=animal+in+%28dog%29&page=1&per_page=50" }, - 'last' => { 'href' => "#{link_prefix}/v3/routes?domain_guids=#{domain1.guid}&label_selector=animal+in+%28dog%29&page=1&per_page=50" }, - 'next' => nil, - 'previous' => nil - } - - expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route1.guid) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - - it 'returns a 200 and the filtered routes for "in" label selector with host filters' do - get '/v3/routes?label_selector=animal in (dog)&hosts=hall', nil, admin_header - - expect(last_response).to have_status_code(200), last_response.body - parsed_response = Oj.load(last_response.body) - - expected_pagination = { - 'total_results' => 1, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/routes?hosts=hall&label_selector=animal+in+%28dog%29&page=1&per_page=50" }, - 'last' => { 'href' => "#{link_prefix}/v3/routes?hosts=hall&label_selector=animal+in+%28dog%29&page=1&per_page=50" }, - 'next' => nil, - 'previous' => nil - } - - expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route1.guid) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - - it 'returns a 200 and the filtered routes for "in" label selector with path filters' do - get '/v3/routes?label_selector=animal in (dog)&paths=/oates', nil, admin_header - - expect(last_response).to have_status_code(200) - parsed_response = Oj.load(last_response.body) - - expected_pagination = { - 'total_results' => 1, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal+in+%28dog%29&page=1&paths=%2Foates&per_page=50" }, - 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal+in+%28dog%29&page=1&paths=%2Foates&per_page=50" }, - 'next' => nil, - 'previous' => nil - } - - expect(last_response).to have_status_code(200) - expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route1.guid) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - end - - it 'returns a 200 and the filtered routes for "notin" label selector' do - get '/v3/routes?label_selector=animal notin (dog)', nil, admin_header - - parsed_response = Oj.load(last_response.body) - - expected_pagination = { - 'total_results' => 3, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal+notin+%28dog%29&page=1&per_page=50" }, - 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal+notin+%28dog%29&page=1&per_page=50" }, - 'next' => nil, - 'previous' => nil - } - - expect(last_response).to have_status_code(200) - expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route2.guid, route_in_org.guid, route_in_other_org.guid) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - - it 'returns a 200 and the filtered routes for "=" label selector' do - get '/v3/routes?label_selector=animal=dog', nil, admin_header - - parsed_response = Oj.load(last_response.body) - - expected_pagination = { - 'total_results' => 1, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal%3Ddog&page=1&per_page=50" }, - 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal%3Ddog&page=1&per_page=50" }, - 'next' => nil, - 'previous' => nil - } - - expect(last_response).to have_status_code(200) - expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route1.guid) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - - it 'returns a 200 and the filtered domains for "==" label selector' do - get '/v3/routes?label_selector=animal==dog', nil, admin_header - - parsed_response = Oj.load(last_response.body) - - expected_pagination = { - 'total_results' => 1, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal%3D%3Ddog&page=1&per_page=50" }, - 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal%3D%3Ddog&page=1&per_page=50" }, - 'next' => nil, - 'previous' => nil - } - - expect(last_response).to have_status_code(200) - expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route1.guid) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - - it 'returns a 200 and the filtered routes for "!=" label selector' do - get '/v3/routes?label_selector=animal!=dog', nil, admin_header - - parsed_response = Oj.load(last_response.body) - - expected_pagination = { - 'total_results' => 3, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal%21%3Ddog&page=1&per_page=50" }, - 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal%21%3Ddog&page=1&per_page=50" }, - 'next' => nil, - 'previous' => nil - } - - expect(last_response).to have_status_code(200) - expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route2.guid, route_in_org.guid, route_in_other_org.guid) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - - it 'returns a 200 and the filtered routes for "=" label selector' do - get '/v3/routes?label_selector=animal=cow,santa=claus', nil, admin_header - - parsed_response = Oj.load(last_response.body) - - expected_pagination = { - 'total_results' => 1, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal%3Dcow%2Csanta%3Dclaus&page=1&per_page=50" }, - 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal%3Dcow%2Csanta%3Dclaus&page=1&per_page=50" }, - 'next' => nil, - 'previous' => nil - } - - expect(last_response).to have_status_code(200) - expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route2.guid) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - - it 'returns a 200 and the filtered routes for existence label selector' do - get '/v3/routes?label_selector=santa', nil, admin_header - - parsed_response = Oj.load(last_response.body) - - expected_pagination = { - 'total_results' => 1, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=santa&page=1&per_page=50" }, - 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=santa&page=1&per_page=50" }, - 'next' => nil, - 'previous' => nil - } - - expect(last_response).to have_status_code(200) - expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route2.guid) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - - it 'returns a 200 and the filtered routes for non-existence label selector' do - get '/v3/routes?label_selector=!santa', nil, admin_header - - parsed_response = Oj.load(last_response.body) - - expected_pagination = { - 'total_results' => 3, - 'total_pages' => 1, - 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=%21santa&page=1&per_page=50" }, - 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=%21santa&page=1&per_page=50" }, - 'next' => nil, - 'previous' => nil - } - - expect(last_response).to have_status_code(200) - expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route1.guid, route_in_org.guid, route_in_other_org.guid) - expect(parsed_response['pagination']).to eq(expected_pagination) - end - end - - describe 'eager loading' do - it 'eager loads associated resources that the presenter specifies' do - expect(VCAP::CloudController::RouteFetcher).to receive(:fetch).with( - anything, - hash_including(eager_loaded_associations: %i[domain space route_mappings labels annotations]) - ).and_call_original - - get '/v3/routes', nil, admin_header - expect(last_response).to have_status_code(200) - end - end - - context 'when the request is invalid' do - it 'returns 400 with a meaningful error' do - get '/v3/routes?page=potato', nil, admin_header - expect(last_response).to have_status_code(400) - expect(last_response).to have_error_message('The query parameter is invalid: Page must be a positive integer') - end - end - - context 'when the user is not logged in' do - it 'returns 401 for Unauthenticated requests' do - get '/v3/routes', nil, base_json_headers - expect(last_response).to have_status_code(401) - end - end - end - - describe 'GET /v3/routes/:guid' do - let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } - let(:route) { VCAP::CloudController::Route.make(space:, domain:) } - let(:api_call) { ->(user_headers) { get "/v3/routes/#{route.guid}", nil, user_headers } } - let(:route_json) do - { - guid: route.guid, - protocol: route.domain.protocols[0], - host: route.host, - path: route.path, - port: nil, - url: "#{route.host}.#{route.domain.name}#{route.path}", - created_at: iso8601, - updated_at: iso8601, - destinations: [], - relationships: { - space: { - data: { guid: route.space.guid } - }, - domain: { - data: { guid: route.domain.guid } - } - }, - metadata: { - labels: {}, - annotations: {} - }, - options: {}, - links: { - self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, - space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{route.space.guid}} }, - destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, - domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{route.domain.guid}} } - } - } - end - - context 'when the user is a member in the routes org' do - let(:expected_codes_and_responses) do - h = Hash.new( - { code: 200, - response_object: route_json }.freeze - ) - - h['org_billing_manager'] = { code: 404 } - h['no_role'] = { code: 404 } - h - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - - describe 'when the user is not logged in' do - it 'returns 401 for Unauthenticated requests' do - get "/v3/routes/#{route.guid}", nil, base_json_headers - expect(last_response).to have_status_code(401) - end - end - - describe 'includes' do - context 'when including domains' do - let(:domain_json) do - { - guid: domain.guid, - created_at: iso8601, - updated_at: iso8601, - name: domain.name, - internal: false, - router_group: nil, - supported_protocols: ['http'], - metadata: { - labels: {}, - annotations: {} - }, - relationships: { - organization: { - data: { guid: domain.owning_organization.guid } - }, - shared_organizations: { - data: [] - } - }, - links: { - self: { href: "#{link_prefix}/v3/domains/#{domain.guid}" }, - organization: { href: %r{#{Regexp.escape(link_prefix)}/v3/organizations/#{domain.owning_organization.guid}} }, - route_reservations: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}/route_reservations} }, - shared_organizations: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}/relationships/shared_organizations} } - } - } - end - let(:route_json) do - { - guid: route.guid, - protocol: route.domain.protocols[0], - host: route.host, - path: route.path, - port: nil, - url: "#{route.host}.#{route.domain.name}#{route.path}", - created_at: iso8601, - updated_at: iso8601, - destinations: [], - relationships: { - space: { - data: { guid: route.space.guid } - }, - domain: { - data: { guid: route.domain.guid } - } - }, - metadata: { - labels: {}, - annotations: {} - }, - options: {}, - links: { - self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, - space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{route.space.guid}} }, - destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, - domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{route.domain.guid}} } - }, - included: { domains: [domain_json] } - } - end - - it 'includes the domain for the route' do - get "/v3/routes/#{route.guid}?include=domain", nil, admin_header - expect(last_response).to have_status_code(200), last_response.body - expect(parsed_response).to match_json_response(route_json) - end - end - - context 'when including spaces and orgs' do - it 'includes the unique spaces and organizations for the routes' do - get "/v3/routes/#{route.guid}?include=space,space.organization", nil, admin_header - expect(last_response).to have_status_code(200) - expect(parsed_response['included']).to match_json_response( - 'spaces' => [ - space_json_generator.call(space) - ], - 'organizations' => [ - org_json_generator.call(org) - ] - ) - end - - context 'user is org_auditor' do - let(:user_header) { set_user_with_header_as_role(user: user, role: 'org_auditor', org: org) } - - it 'includes the unique organizations for the routes, but no spaces' do - get "/v3/routes/#{route.guid}?include=space,space.organization", nil, user_header - expect(last_response).to have_status_code(200) - expect(parsed_response['included']).to match_json_response( - 'spaces' => [], - 'organizations' => [ - org_json_generator.call(org) - ] - ) - end - end - end - end - end - - describe 'POST /v3/routes' do - context 'when creating a route in a tcp domain' do - let(:domain) { VCAP::CloudController::SharedDomain.make(router_group_guid: 'some-router-group', router_group_type: 'tcp') } - - before do - token = { token_type: 'Bearer', access_token: 'my-favourite-access-token' } - stub_request(:post, 'https://uaa.service.cf.internal/oauth/token'). - to_return(status: 200, body: token.to_json, headers: { 'Content-Type' => 'application/json' }) - stub_request(:get, 'http://localhost:3000/routing/v1/router_groups'). - to_return(status: 200, body: '[{"guid":"some-router-group","name":"Robby Router","type":"tcp","reservable_ports":"25555"}]', headers: {}) - end - - context 'and the route has a host' do - let(:params) do - { - host: 'my-host', - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - it 'returns a 422 and a helpful error message' do - post '/v3/routes', params.to_json, admin_header - expect(last_response).to have_status_code(422) - expect(last_response).to have_error_message('Hosts are not supported for TCP routes.') - end - end - - context 'and the route has a path' do - let(:params) do - { - path: '/cgi-bin', - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - it 'returns a 422 and a helpful error message' do - post '/v3/routes', params.to_json, admin_header - expect(last_response).to have_status_code(422) - expect(last_response).to have_error_message('Paths are not supported for TCP routes.') - end - end - end - - context 'when creating a route in a scoped domain' do - let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } - - describe 'when creating a route without a host' do - let(:params) do - { - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - let(:route_json) do - { - guid: UUID_REGEX, - protocol: domain.protocols[0], - host: '', - path: '', - port: nil, - url: domain.name, - created_at: iso8601, - updated_at: iso8601, - destinations: [], - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - }, - links: { - self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, - space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}} }, - destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, - domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } - }, - metadata: { - labels: {}, - annotations: {} - }, - options: {} - } - end - - describe 'valid routes' do - let(:api_call) { ->(user_headers) { post '/v3/routes', params.to_json, user_headers } } - - let(:expected_codes_and_responses) do - h = Hash.new( - { code: 403 }.freeze - ) - h['admin'] = { - code: 201, - response_object: route_json - } - h['space_developer'] = { - code: 201, - response_object: route_json - } - h['space_supporter'] = { - code: 201, - response_object: route_json - } - h - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - end - - describe 'when creating a route with a host' do - let(:params) do - { - host: 'some-host', - path: '/some-path', - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - }, - metadata: { - labels: { potato: 'yam' }, - annotations: { style: 'mashed' } - }, - options: {} - } - end - - let(:route_json) do - { - guid: UUID_REGEX, - protocol: domain.protocols[0], - host: 'some-host', - path: '/some-path', - port: nil, - url: "some-host.#{domain.name}/some-path", - created_at: iso8601, - updated_at: iso8601, - destinations: [], - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - }, - links: { - self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, - space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}} }, - destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, - domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } - }, - metadata: { - labels: { potato: 'yam' }, - annotations: { style: 'mashed' } - }, - options: {} - } - end - - describe 'valid routes' do - it_behaves_like 'permissions for single object endpoint', ['admin'] do - let(:api_call) { ->(user_headers) { post '/v3/routes', params.to_json, user_headers } } - - let(:expected_codes_and_responses) do - h = Hash.new( - { code: 403 }.freeze - ) - h['admin'] = { - code: 201, - response_object: route_json - } - h['space_developer'] = { - code: 201, - response_object: route_json - } - h['space_supporter'] = { - code: 201, - response_object: route_json - } - h - end - - let(:expected_event_hash) do - { - type: 'audit.route.create', - actee: parsed_response['guid'], - actee_type: 'route', - actee_name: 'some-host', - metadata: { request: params }.to_json, - space_guid: space.guid, - organization_guid: org.guid - } - end - end - end - end - - describe 'when creating a route with a wildcard host' do - let(:params) do - { - host: '*', - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - let(:route_json) do - { - guid: UUID_REGEX, - protocol: domain.protocols[0], - host: '*', - path: '', - port: nil, - url: "*.#{domain.name}", - created_at: iso8601, - updated_at: iso8601, - destinations: [], - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - }, - links: { - self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, - space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}} }, - destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, - domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } - }, - metadata: { - labels: {}, - annotations: {} - }, - options: {} - } - end - - describe 'valid routes' do - let(:api_call) { ->(user_headers) { post '/v3/routes', params.to_json, user_headers } } - - let(:expected_codes_and_responses) do - h = Hash.new( - { code: 403 }.freeze - ) - h['admin'] = { - code: 201, - response_object: route_json - } - h['space_developer'] = { - code: 201, - response_object: route_json - } - h['space_supporter'] = { - code: 201, - response_object: route_json - } - h - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - end - end - - context 'when creating a route in an unscoped domain' do - let(:domain) { VCAP::CloudController::SharedDomain.make } - - describe 'when creating a route without a host' do - let(:params) do - { - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - it 'fails with a helpful message' do - post '/v3/routes', params.to_json, admin_header - expect(last_response).to have_status_code(422) - expect(last_response).to have_error_message('Missing host. Routes in shared domains must have a host defined.') - end - end - - describe 'when creating a route with a host' do - let(:params) do - { - host: 'some-host', - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - let(:route_json) do - { - guid: UUID_REGEX, - protocol: domain.protocols[0], - host: 'some-host', - path: '', - port: nil, - url: "some-host.#{domain.name}", - created_at: iso8601, - updated_at: iso8601, - destinations: [], - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - }, - links: { - self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, - space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}} }, - destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, - domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } - }, - metadata: { - labels: {}, - annotations: {} - }, - options: {} - } - end - - describe 'valid routes' do - let(:api_call) { ->(user_headers) { post '/v3/routes', params.to_json, user_headers } } - - let(:expected_codes_and_responses) do - h = Hash.new( - { code: 403 }.freeze - ) - h['admin'] = { - code: 201, - response_object: route_json - } - h['space_developer'] = { - code: 201, - response_object: route_json - } - h['space_supporter'] = { - code: 201, - response_object: route_json - } - h - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - end - - describe 'when creating a route with a wildcard host' do - let(:params) do - { - host: '*', - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - let(:route_json) do - { - guid: UUID_REGEX, - protocol: domain.protocols[0], - host: '*', - path: '', - port: nil, - url: "*.#{domain.name}", - created_at: iso8601, - updated_at: iso8601, - destinations: [], - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - }, - links: { - self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, - space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}} }, - destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, - domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } - }, - metadata: { - labels: {}, - annotations: {} - }, - options: {} - } - end - - describe 'valid routes' do - let(:api_call) { ->(user_headers) { post '/v3/routes', params.to_json, user_headers } } - - let(:expected_codes_and_responses) do - h = Hash.new( - { code: 403 }.freeze - ) - h['admin'] = { - code: 201, - response_object: route_json - } - h['space_developer'] = { - code: 422 - } - h['space_supporter'] = { - code: 422 - } - h - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - end - - describe 'the domain supports tcp routes' do - let(:router_group) { VCAP::CloudController::RoutingApi::RouterGroup.new({ 'type' => 'tcp', 'reservable_ports' => '123' }) } - let(:domain) { VCAP::CloudController::SharedDomain.make(router_group_guid: 'some-router-group', name: 'my.domain') } - let(:routing_api_client) { instance_double(VCAP::CloudController::RoutingApi::Client) } - - before do - TestConfig.override( - kubernetes: { host_url: nil }, - external_domain: 'api2.vcap.me', - external_protocol: 'https' - ) - allow_any_instance_of(CloudController::DependencyLocator).to receive(:routing_api_client).and_return(routing_api_client) - allow(routing_api_client).to receive_messages(enabled?: true, router_group: router_group) - end - - let(:params) do - { - port: 123, - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - let(:api_call) { ->(user_headers) { post '/v3/routes', params.to_json, user_headers } } - - let(:route_json) do - { - guid: UUID_REGEX, - port: 123, - host: '', - path: '', - protocol: 'tcp', - url: "#{domain.name}:123", - created_at: iso8601, - updated_at: iso8601, - destinations: [], - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - }, - links: { - self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, - space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}} }, - destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, - domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } - }, - metadata: { - labels: {}, - annotations: {} - }, - options: {} - } - end - - let(:expected_codes_and_responses) do - h = Hash.new( - { code: 403 }.freeze - ) - h['admin'] = { - code: 201, - response_object: route_json - } - h['space_developer'] = { - code: 201, - response_object: route_json - } - h['space_supporter'] = { - code: 201, - response_object: route_json - } - h - end - - context 'and the user provides a valid port' do - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - - context 'and a route with the domain and port already exist' do - let!(:duplicate_route) { VCAP::CloudController::Route.make(host: '', space: space, domain: domain, port: 123) } - - it 'fails with a helpful error message' do - post '/v3/routes', params.to_json, admin_headers - expect(last_response).to have_status_code(422) - expect(last_response).to have_error_message("Route already exists with port '123' for domain 'my.domain'.") - end - end - - context 'and the port is already in use for the router group' do - let!(:other_domain) { VCAP::CloudController::SharedDomain.make(router_group_guid: 'some-router-group', name: 'my.domain2') } - let!(:route_with_port) { VCAP::CloudController::Route.make(host: '', space: space, domain: other_domain, port: 123) } - - it 'fails with a helpful error message' do - post '/v3/routes', params.to_json, admin_headers - expect(last_response).to have_status_code(422) - expect(last_response).to have_error_message("Port '123' is not available. Try a different port or use a different domain.") - end - end - end - - context 'and the user does not provide a port' do - let(:params) do - { - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - - context 'and randomly selected port is already in use' do - let(:existing_route) { VCAP::CloudController::Route.make(host: '', space: space, domain: domain, port: 123) } - - let(:params) do - { - port: existing_route.port, - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - it 'fails with a helpful error message' do - post '/v3/routes', params.to_json, admin_headers - expect(last_response).to have_status_code(422) - expect(last_response).to have_error_message("Route already exists with port '123' for domain 'my.domain'.") - end - end - end - end - end - - context 'when creating a route in a suspended org' do - before do - org.update(status: VCAP::CloudController::Organization::SUSPENDED) - end - - let(:domain) { VCAP::CloudController::SharedDomain.make } - - let(:params) do - { - host: 'some-host', - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - let(:route_json) do - { - guid: UUID_REGEX, - protocol: domain.protocols[0], - host: 'some-host', - path: '', - port: nil, - url: "some-host.#{domain.name}", - created_at: iso8601, - updated_at: iso8601, - destinations: [], - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - }, - metadata: { - labels: {}, - annotations: {} - }, - links: { - self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, - space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}} }, - destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, - domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } - }, - options: {} - } - end - - describe 'valid routes' do - let(:api_call) { ->(user_headers) { post '/v3/routes', params.to_json, user_headers } } - - let(:expected_codes_and_responses) do - h = Hash.new({ code: 403, errors: CF_NOT_AUTHORIZED }.freeze) - h['admin'] = { - code: 201, - response_object: route_json - } - %w[space_developer space_supporter].each { |r| h[r] = { code: 403, errors: CF_ORG_SUSPENDED } } - h - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - end - - context 'when creating a route in an internal domain' do - let(:domain) { VCAP::CloudController::SharedDomain.make(internal: true) } - - describe 'when creating a route with a wildcard host' do - let(:params) do - { - host: '*', - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - it 'fails with a helpful message' do - post '/v3/routes', params.to_json, admin_header - expect(last_response).to have_status_code(422) - expect(last_response).to have_error_message('Wildcard hosts are not supported for internal domains.') - end - end - - describe 'when creating a route with a path' do - let(:params) do - { - host: 'host', - path: '/apath', - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - it 'fails with a helpful message' do - post '/v3/routes', params.to_json, admin_header - expect(last_response).to have_status_code(422) - expect(last_response).to have_error_message('Paths are not supported for internal domains.') - end - end - - describe 'when creating a route with a host' do - let(:params) do - { - host: 'some-host', - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - let(:route_json) do - { - guid: UUID_REGEX, - protocol: domain.protocols[0], - host: 'some-host', - path: '', - port: nil, - url: "some-host.#{domain.name}", - created_at: iso8601, - updated_at: iso8601, - destinations: [], - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - }, - links: { - self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, - space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}} }, - destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, - domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } - }, - metadata: { - labels: {}, - annotations: {} - }, - options: {} - } - end - - describe 'valid routes' do - let(:api_call) { ->(user_headers) { post '/v3/routes', params.to_json, user_headers } } - - let(:expected_codes_and_responses) do - h = Hash.new( - { code: 403 }.freeze - ) - h['admin'] = { - code: 201, - response_object: route_json - } - h['space_developer'] = { - code: 201, - response_object: route_json - } - h['space_supporter'] = { - code: 201, - response_object: route_json - } - h - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - end - end - - context 'when the domain has an owning org that is different from the space\'s parent org' do - let(:other_org) { VCAP::CloudController::Organization.make } - let(:inaccessible_domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: other_org) } - - let(:params_with_inaccessible_domain) do - { - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: inaccessible_domain.guid } - } - } - } - end - - it 'returns a 422 with a helpful error message' do - post '/v3/routes', params_with_inaccessible_domain.to_json, admin_header - expect(last_response).to have_status_code(422) - expect(last_response).to have_error_message("Invalid domain. Domain '#{inaccessible_domain.name}' is not available in organization '#{org.name}'.") - end - end - - context 'when the host-less route has already been created for this domain' do - let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } - let!(:existing_route) { VCAP::CloudController::Route.make(host: '', space: space, domain: domain) } - - let(:params_for_duplicate_route) do - { - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - it 'returns a 422 with a helpful error message' do - post '/v3/routes', params_for_duplicate_route.to_json, admin_header - expect(last_response).to have_status_code(422) - expect(last_response).to have_error_message("Route already exists for domain '#{domain.name}'.") - end - end - - context 'when there is already a route' do - context 'with the host/domain/path combination' do - let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } - let!(:existing_route) { VCAP::CloudController::Route.make(host: 'my-host', path: '/existing', space: space, domain: domain) } - - let(:params_for_duplicate_route) do - { - host: existing_route.host, - path: existing_route.path, - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - it 'returns a 422 with a helpful error message' do - post '/v3/routes', params_for_duplicate_route.to_json, admin_header - expect(last_response).to have_status_code(422) - expect(last_response).to have_error_message("Route already exists with host '#{existing_route.host}' and path '#{existing_route.path}' for domain '#{domain.name}'.") - end - end - - context 'with the host/domain combination' do - let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } - let!(:existing_route) { VCAP::CloudController::Route.make(host: 'my-host', space: space, domain: domain) } - - let(:params_for_duplicate_route) do - { - host: existing_route.host, - path: existing_route.path, - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - it 'returns a 422 with a helpful error message' do - post '/v3/routes', params_for_duplicate_route.to_json, admin_header - expect(last_response).to have_status_code(422) - expect(last_response).to have_error_message("Route already exists with host '#{existing_route.host}' for domain '#{domain.name}'.") - end - end - end - - context 'when there is already a domain matching the host/domain combination' do - let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } - let!(:existing_domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization, name: "#{params[:host]}.#{domain.name}") } - - let(:params) do - { - host: 'some-host', - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - it 'returns a 422 with a helpful error message' do - post '/v3/routes', params.to_json, admin_header - expect(last_response).to have_status_code(422) - expect(last_response).to have_error_message("Route conflicts with domain '#{existing_domain.name}'.") - end - end - - context 'when using a reserved system hostname with the system domain' do - let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } - - let(:params) do - { - host: 'host', - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - before do - VCAP::CloudController::Config.config.set(:system_domain, domain.name) - VCAP::CloudController::Config.config.set(:system_hostnames, [params[:host]]) - end - - it 'returns a 422 with a helpful error message' do - post '/v3/routes', params.to_json, admin_header - expect(last_response).to have_status_code(422) - expect(last_response).to have_error_message('Route conflicts with a reserved system route.') - end - end - - context 'when using a non-reserved hostname with the system domain' do - let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } - let(:api_call) { ->(user_headers) { post '/v3/routes', params.to_json, user_headers } } - - let(:params) do - { - host: 'host', - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - let(:route_json) do - { - guid: UUID_REGEX, - protocol: domain.protocols[0], - host: params[:host], - path: '', - port: nil, - url: "#{params[:host]}.#{domain.name}", - created_at: iso8601, - updated_at: iso8601, - destinations: [], - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - }, - metadata: { - labels: {}, - annotations: {} - }, - links: { - self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, - space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}} }, - destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, - domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } - }, - options: {} - } - end - - let(:expected_codes_and_responses) do - h = Hash.new( - { code: 403 }.freeze - ) - h['admin'] = { - code: 201, - response_object: route_json - } - h['space_developer'] = { - code: 201, - response_object: route_json - } - h['space_supporter'] = { - code: 201, - response_object: route_json - } - h - end - - before do - VCAP::CloudController::Config.config.set(:system_domain, domain.name) - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - - describe 'quotas' do - context 'when the space quota for routes is maxed out' do - let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } - let!(:space_quota_definition) { VCAP::CloudController::SpaceQuotaDefinition.make(total_routes: 0, organization: org) } - let!(:space_with_quota) { VCAP::CloudController::Space.make(space_quota_definition: space_quota_definition, organization: org) } - - let(:params_for_space_with_quota) do - { - relationships: { - space: { - data: { guid: space_with_quota.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - it 'returns a 422 with a helpful error message' do - post '/v3/routes', params_for_space_with_quota.to_json, admin_header - expect(last_response).to have_status_code(422) - expect(last_response).to have_error_message("Routes quota exceeded for space '#{space_with_quota.name}'.") - end - end - - context 'when the org quota for routes is maxed out' do - let!(:org_quota_definition) { VCAP::CloudController::QuotaDefinition.make(total_routes: 0, total_reserved_route_ports: 0) } - let!(:org_with_quota) { VCAP::CloudController::Organization.make(quota_definition: org_quota_definition) } - let!(:space_in_org_with_quota) do - VCAP::CloudController::Space.make(organization: org_with_quota) - end - let(:domain_in_org_with_quota) { VCAP::CloudController::Domain.make(owning_organization: org_with_quota) } - - let(:params_for_org_with_quota) do - { - relationships: { - space: { - data: { guid: space_in_org_with_quota.guid } - }, - domain: { - data: { guid: domain_in_org_with_quota.guid } - } - } - } - end - - it 'returns a 422 with a helpful error message' do - post '/v3/routes', params_for_org_with_quota.to_json, admin_header - expect(last_response).to have_status_code(422) - expect(last_response).to have_error_message("Routes quota exceeded for organization '#{org_with_quota.name}'.") - end - end - end - - context 'when the feature flag is disabled' do - let(:headers) { set_user_with_header_as_role(user: user, role: 'space_developer', org: org, space: space) } - let!(:feature_flag) { VCAP::CloudController::FeatureFlag.make(name: 'route_creation', enabled: false, error_message: 'my name is bob') } - let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } - let(:params) do - { - host: 'some-host', - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - context 'when the user is not an admin' do - it 'returns a 403' do - post '/v3/routes', params.to_json, headers - - expect(last_response).to have_status_code(403) - expect(parsed_response['errors'][0]['detail']).to eq('Feature Disabled: my name is bob') - end - end - - context 'when the user is an admin' do - let(:headers) { set_user_with_header_as_role(role: 'admin') } - - it 'allows creation' do - post '/v3/routes', params.to_json, headers - - expect(last_response).to have_status_code(201) - end - end - end - - context 'when the user is not logged in' do - it 'returns 401 for Unauthenticated requests' do - post '/v3/routes', {}.to_json, base_json_headers - expect(last_response).to have_status_code(401) - end - end - - context 'when the user does not have the required scopes' do - let(:user_header) { headers_for(user, scopes: ['cloud_controller.read']) } - - it 'returns a 403' do - post '/v3/routes', {}.to_json, user_header - expect(last_response).to have_status_code(403) - end - end - - context 'when the space does not exist' do - let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } - - let(:params_with_invalid_space) do - { - relationships: { - space: { - data: { guid: 'invalid-space' } - }, - domain: { - data: { guid: domain.guid } - } - } - } - end - - it 'returns a 422 with a helpful error message' do - post '/v3/routes', params_with_invalid_space.to_json, admin_header - expect(last_response).to have_status_code(422) - expect(last_response).to have_error_message('Invalid space. Ensure that the space exists and you have access to it.') - end - end - - context 'when the domain does not exist' do - let(:params_with_invalid_domain) do - { - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: 'invalid-domain' } - } - } - } - end - - it 'returns a 422 with a helpful error message' do - post '/v3/routes', params_with_invalid_domain.to_json, admin_header - expect(last_response).to have_status_code(422) - expect(last_response).to have_error_message('Invalid domain. Ensure that the domain exists and you have access to it.') - end - end - - context 'when communicating with the routing API' do - let(:routing_api_client) { instance_double(VCAP::CloudController::RoutingApi::Client) } - let(:router_group) { VCAP::CloudController::RoutingApi::RouterGroup.new({ 'type' => 'tcp', 'guid' => 'some-guid' }) } - let(:headers) { set_user_with_header_as_role(role: 'admin') } - let(:domain_tcp) { VCAP::CloudController::SharedDomain.make(router_group_guid: router_group.guid, name: 'my.domain') } - let(:params) do - { - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain_tcp.guid } - } - } - } - end - - before do - allow_any_instance_of(CloudController::DependencyLocator).to receive(:routing_api_client).and_return(routing_api_client) - end - - context 'when UAA is unavailable' do - before do - allow(routing_api_client).to receive(:router_group).and_raise VCAP::CloudController::RoutingApi::UaaUnavailable - end - - it 'returns a 503 with a helpful error message' do - post '/v3/routes', params.to_json, headers - - expect(last_response).to have_status_code(503) - expect(parsed_response['errors'][0]['detail']).to eq 'Communicating with the Routing API failed because UAA is currently unavailable. Please try again later.' - end - end - - context 'when the routing API is unavailable' do - before do - allow(routing_api_client).to receive(:enabled?).and_return true - allow(routing_api_client).to receive(:router_group).and_raise VCAP::CloudController::RoutingApi::RoutingApiUnavailable - end - - it 'returns a 503 with a helpful error message' do - post '/v3/routes', params.to_json, headers - - expect(last_response).to have_status_code(503) - expect(parsed_response['errors'][0]['detail']).to eq 'The Routing API is currently unavailable. Please try again later.' - end - end - - context 'when the routing API is disabled' do - before do - allow(routing_api_client).to receive(:enabled?).and_return false - allow(routing_api_client).to receive(:router_group).and_raise VCAP::CloudController::RoutingApi::RoutingApiDisabled - end - - it 'returns a 503 with a helpful error message' do - post '/v3/routes', params.to_json, headers - - expect(last_response).to have_status_code(503) - expect(parsed_response['errors'][0]['detail']).to eq 'The Routing API is disabled.' - end - end - - context 'when the router group is unavailable' do - let(:domain_tcp) { VCAP::CloudController::SharedDomain.make(router_group_guid: 'not a guid', name: 'my.domain') } - - before do - allow(routing_api_client).to receive_messages(enabled?: true, router_group: nil) - end - - it 'returns a 503 with a helpful error message' do - post '/v3/routes', params.to_json, headers - - expect(last_response.status).to eq(422) - expect(parsed_response['errors'][0]['detail']).to eq 'Route could not be created because the specified domain does not have a valid router group.' - end - end - end - end - - describe 'PATCH /v3/routes/:guid' do - let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } - let(:route) { VCAP::CloudController::Route.make(space: space, domain: domain, host: '') } - let(:api_call) { ->(user_headers) { patch "/v3/routes/#{route.guid}", params.to_json, user_headers } } - let(:params) do - { - metadata: { - labels: { - potato: 'fingerling', - style: 'roasted' - }, - annotations: { - potato: 'russet', - style: 'fried' - } - } - } - end - - let(:route_json) do - { - guid: UUID_REGEX, - protocol: domain.protocols[0], - host: '', - path: '', - port: nil, - url: domain.name, - created_at: iso8601, - updated_at: iso8601, - destinations: [], - relationships: { - space: { - data: { guid: space.guid } - }, - domain: { - data: { guid: domain.guid } - } - }, - links: { - self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, - space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}} }, - destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, - domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } - }, - metadata: { - labels: { - potato: 'fingerling', - style: 'roasted' - }, - annotations: { - potato: 'russet', - style: 'fried' - } - }, - options: {} - } - end - - context 'when the user logged in' do - let(:expected_codes_and_responses) do - h = Hash.new({ code: 403, errors: CF_NOT_AUTHORIZED }.freeze) - h['admin'] = { code: 200, response_object: route_json } - h['no_role'] = { code: 404 } - h['org_billing_manager'] = { code: 404 } - h['space_developer'] = { code: 200, response_object: route_json } - h['space_supporter'] = { code: 200, response_object: route_json } - h - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - - context 'when organization is suspended' do - let(:expected_codes_and_responses) do - h = super() - %w[space_developer space_supporter].each { |r| h[r] = { code: 403, errors: CF_ORG_SUSPENDED } } - h - end - - before do - org.update(status: VCAP::CloudController::Organization::SUSPENDED) - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - end - - context 'when the user is not a member in the routes org' do - let(:other_space) { VCAP::CloudController::Space.make } - let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: other_space.organization) } - let(:route) { VCAP::CloudController::Route.make(space: other_space, domain: domain, host: '') } - - let(:route_json) do - { - guid: UUID_REGEX, - protocol: domain.protocols[0], - host: '', - path: '', - port: nil, - url: domain.name, - created_at: iso8601, - updated_at: iso8601, - destinations: [], - relationships: { - space: { - data: { guid: other_space.guid } - }, - domain: { - data: { guid: domain.guid } - } - }, - links: { - self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, - space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{other_space.guid}} }, - destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, - domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } - }, - metadata: { - labels: { - potato: 'fingerling', - style: 'roasted' - }, - annotations: { - potato: 'russet', - style: 'fried' - } - }, - options: {} - } - end - let(:expected_codes_and_responses) do - h = Hash.new({ code: 404 }.freeze) - h['admin'] = { - code: 200, - response_object: route_json - } - h['admin_read_only'] = { - code: 403 - } - h['global_auditor'] = { - code: 403 - } - h - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - - context 'when route does not exist' do - it 'returns a 404 with a helpful error message' do - patch "/v3/routes/#{user.guid}", params.to_json, admin_header - - expect(last_response).to have_status_code(404) - expect(last_response).to have_error_message('Route not found') - end - end - - context 'when request input message is invalid' do - let(:params_with_invalid_input) do - { - disallowed_key: 'val' - } - end - - it 'returns a 422' do - patch "/v3/routes/#{route.guid}", params_with_invalid_input.to_json, admin_header - - expect(last_response).to have_status_code(422) - end - end - - context 'when metadata is given with invalid format' do - let(:params_with_invalid_metadata_format) do - { - metadata: { - labels: { - "": 'mashed', - '/potato': '.value.' - } - } - } - end - - it 'returns a 422' do - patch "/v3/routes/#{route.guid}", params_with_invalid_metadata_format.to_json, admin_header - - expect(last_response).to have_status_code(422) - expect(parsed_response['errors'][0]['detail']).to match(/Metadata [\w\s]+ error/) - end - end - - context 'when the user is not logged in' do - it 'returns 401 for Unauthenticated requests' do - patch "/v3/routes/#{route.guid}", nil, base_json_headers - expect(last_response).to have_status_code(401) - end - end - end - - describe 'DELETE /v3/routes/:guid' do - let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } - let(:route) { VCAP::CloudController::Route.make(space:, domain:) } - let(:api_call) { ->(user_headers) { delete "/v3/routes/#{route.guid}", nil, user_headers } } - let(:db_check) do - lambda do - expect(last_response.headers['Location']).to match(%r{http.+/v3/jobs/[a-fA-F0-9-]+}) - - execute_all_jobs(expected_successes: 1, expected_failures: 0) - get "/v3/routes/#{route.guid}", {}, admin_headers - expect(last_response).to have_status_code(404) - end - end - - context 'deleting metadata' do - it_behaves_like 'resource with metadata' do - let(:resource) { route } - let(:api_call) do - -> { delete "/v3/routes/#{route.guid}", nil, admin_header } - end - end - end - - context 'when the user is a member in the routes org' do - let(:expected_codes_and_responses) do - h = Hash.new({ code: 403, errors: CF_NOT_AUTHORIZED }.freeze) - h['org_billing_manager'] = { code: 404 } - h['no_role'] = { code: 404 } - h['admin'] = { code: 202 } - h['space_developer'] = { code: 202 } - h['space_supporter'] = { code: 202 } - h - end - - it_behaves_like 'permissions for delete endpoint', ALL_PERMISSIONS do - let(:expected_event_hash) do - { - type: 'audit.route.delete-request', - actee: route.guid, - actee_type: 'route', - actee_name: route.host, - metadata: { request: { recursive: true } }.to_json, - space_guid: space.guid, - organization_guid: org.guid - } - end - end - - context 'when organization is suspended' do - let(:expected_codes_and_responses) do - h = super() - %w[space_developer space_supporter].each { |r| h[r] = { code: 403, errors: CF_ORG_SUSPENDED } } - h - end - - before do - org.update(status: VCAP::CloudController::Organization::SUSPENDED) - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - end - - describe 'when the user is not logged in' do - it 'returns 401 for Unauthenticated requests' do - delete "/v3/routes/#{route.guid}", nil, base_json_headers - expect(last_response).to have_status_code(401) - end - end - end - - describe 'GET /v3/routes/:guid/relationships/shared_spaces' do - let(:api_call) { ->(user_headers) { get "/v3/routes/#{guid}/relationships/shared_spaces", nil, user_headers } } - let(:target_space_1) { VCAP::CloudController::Space.make(organization: org) } - let(:route) do - route = VCAP::CloudController::Route.make(space:) - route.add_shared_space(target_space_1) - route - end - let(:guid) { route.guid } - let(:space_dev_headers) do - org.add_user(user) - space.add_developer(user) - headers_for(user) - end - let!(:feature_flag) do - VCAP::CloudController::FeatureFlag.make(name: 'route_sharing', enabled: true, error_message: nil) - end - - before do - org.add_user(user) - target_space_1.add_developer(user) - end - - describe 'permissions' do - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS do - let(:expected_codes_and_responses) do - h = Hash.new({ code: 200, response_object: { - data: [ - { - guid: target_space_1.guid - } - ], - links: { - self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{route.guid}/relationships/shared_spaces} } - } - } }.freeze) - - h['org_billing_manager'] = { code: 404 } - h['no_role'] = { code: 404 } - h - end - end - end - - describe 'when route_sharing flag is disabled' do - before do - feature_flag.enabled = false - feature_flag.save - end - - it 'makes users unable to unshare routes' do - api_call.call(space_dev_headers) - - expect(last_response).to have_status_code(403) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => 'Feature Disabled: route_sharing', - 'title' => 'CF-FeatureDisabled', - 'code' => 330_002 - } - ) - ) - end - end - - it 'responds with 404 when the route does not exist' do - get '/v3/routes/some-fake-guid/relationships/shared_spaces', nil, space_dev_headers - - expect(last_response).to have_status_code(404) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => 'Route not found', - 'title' => 'CF-ResourceNotFound' - } - ) - ) - end - end - - describe 'POST /v3/routes/:guid/relationships/shared_spaces' do - let(:api_call) { ->(user_headers) { post "/v3/routes/#{guid}/relationships/shared_spaces", request_body.to_json, user_headers } } - let(:target_space_1) { VCAP::CloudController::Space.make(organization: org) } - let(:target_space_2) { VCAP::CloudController::Space.make(organization: org) } - let(:request_body) do - { - 'data' => [ - { 'guid' => target_space_1.guid }, - { 'guid' => target_space_2.guid } - ] - } - end - let(:route) { VCAP::CloudController::Route.make(space:) } - let(:guid) { route.guid } - let(:space_dev_headers) do - org.add_user(user) - space.add_developer(user) - headers_for(user) - end - let!(:feature_flag) do - VCAP::CloudController::FeatureFlag.make(name: 'route_sharing', enabled: true, error_message: nil) - end - - before do - org.add_user(user) - target_space_1.add_developer(user) - target_space_2.add_developer(user) - end - - describe 'permissions' do - let(:expected_codes_and_responses) do - h = Hash.new({ code: 403, errors: CF_NOT_AUTHORIZED }.freeze) - - h['org_billing_manager'] = { code: 404 } - h['no_role'] = { code: 404 } - - h['admin'] = { code: 200 } - h['space_developer'] = { code: 200 } - h['space_supporter'] = { code: 200 } - h - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - - context 'when organization is suspended' do - let(:expected_codes_and_responses) do - h = super() - %w[space_developer space_supporter].each { |r| h[r] = { code: 403, errors: CF_ORG_SUSPENDED } } - h - end - - before do - org.update(status: VCAP::CloudController::Organization::SUSPENDED) - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - - context 'when target organization is suspended' do - let(:target_space_1) do - space = VCAP::CloudController::Space.make - space.organization.add_user(user) - space.organization.update(status: VCAP::CloudController::Organization::SUSPENDED) - space - end - - let(:expected_codes_and_responses) do - h = super() - %w[space_developer space_supporter].each { |r| h[r] = { code: 422 } } - h - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - end - - it 'shares the route to the target space and logs audit event' do - api_call.call(space_dev_headers) - - expect(last_response.status).to eq(200) - - event = VCAP::CloudController::Event.last - expect(event.values).to include({ - type: 'audit.route.share', - actor: user.guid, - actee_type: 'route', - actee_name: route.host, - space_guid: space.guid, - organization_guid: space.organization.guid - }) - expect(event.metadata['target_space_guids']).to include(target_space_1.guid, target_space_2.guid) - - route.reload - expect(route.shared_spaces).to include(target_space_1, target_space_2) - end - - it 'reports that the route is now shared' do - api_call.call(space_dev_headers) - - expect(last_response.status).to eq(200) - route.reload - expect(route.shared_spaces).to include(target_space_1, target_space_2) - expect(route).to be_shared - end - - it 'reports that the route is not shared when it has not been shared' do - route.reload - expect(route.shared_spaces).to be_empty - expect(route).not_to be_shared - end - - describe 'when route_sharing flag is disabled' do - before do - feature_flag.enabled = false - feature_flag.save - end - - it 'makes users unable to share routes' do - api_call.call(space_dev_headers) - - expect(last_response).to have_status_code(403) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => 'Feature Disabled: route_sharing', - 'title' => 'CF-FeatureDisabled', - 'code' => 330_002 - } - ) - ) - end - end - - it 'responds with 404 when the route does not exist' do - post '/v3/routes/some-fake-guid/relationships/shared_spaces', request_body.to_json, space_dev_headers - - expect(last_response).to have_status_code(404) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => 'Route not found', - 'title' => 'CF-ResourceNotFound' - } - ) - ) - end - - describe 'when the request body is invalid' do - context 'when it is not a valid relationship' do - let(:request_body) do - { - 'data' => { 'guid' => target_space_1.guid } - } - end - - it 'responds with 422' do - api_call.call(space_dev_headers) - - expect(last_response.status).to eq(422) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => 'Data must be an array', - 'title' => 'CF-UnprocessableEntity' - } - ) - ) - end - end - - context 'when there are additional keys' do - let(:request_body) do - { - 'data' => [ - { 'guid' => target_space_1.guid } - ], - 'fake-key' => 'foo' - } - end - - it 'responds with 422' do - api_call.call(space_dev_headers) - - expect(last_response.status).to eq(422) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => "Unknown field(s): 'fake-key'", - 'title' => 'CF-UnprocessableEntity' - } - ) - ) - end - end - end - - describe 'target space to share to' do - context 'does not exist' do - let(:target_space_guid) { 'fake-target' } - let(:request_body) do - { - 'data' => [ - { 'guid' => target_space_guid } - ] - } - end - - it 'responds with 422' do - api_call.call(space_dev_headers) - - expect(last_response.status).to eq(422) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => "Unable to share route #{route.uri} with spaces ['#{target_space_guid}']. " \ - 'Ensure the spaces exist and that you have access to them.', - 'title' => 'CF-UnprocessableEntity' - } - ) - ) - end - end - - context 'user does not have access to one of the target spaces' do - let(:no_access_target_space) { VCAP::CloudController::Space.make(organization: org) } - let(:request_body) do - { - 'data' => [ - { 'guid' => no_access_target_space.guid }, - { 'guid' => target_space_1.guid } - ] - } - end - - it 'responds with 422 and does not share the route' do - api_call.call(space_dev_headers) - - expect(last_response.status).to eq(422) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => "Unable to share route #{route.uri} with spaces ['#{no_access_target_space.guid}']. " \ - 'Ensure the spaces exist and that you have access to them.', - 'title' => 'CF-UnprocessableEntity' - } - ) - ) - - route.reload - expect(route).not_to be_shared - end - end - - context 'already owns the route' do - let(:request_body) do - { - 'data' => [ - { 'guid' => space.guid }, - { 'guid' => target_space_1.guid } - ] - } - end - - it 'responds with 422 and does not share the route' do - api_call.call(space_dev_headers) - - expect(last_response.status).to eq(422) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => "Unable to share route '#{route.uri}' with space '#{space.guid}'. " \ - 'Routes cannot be shared into the space where they were created.', - 'title' => 'CF-UnprocessableEntity' - } - ) - ) - - route.reload - expect(route).not_to be_shared - end - end - end - - describe 'errors while sharing' do - # isolation segments? - end - end - - describe 'DELETE /v3/routes/:guid/relationships/shared_spaces/:space_guid' do - let(:api_call) { ->(user_headers) { delete "/v3/routes/#{guid}/relationships/shared_spaces/#{unshared_space_guid}", request_body.to_json, user_headers } } - let(:target_space_1) { VCAP::CloudController::Space.make(organization: org) } - let(:target_space_2) { VCAP::CloudController::Space.make(organization: org) } - let(:target_space_3) { VCAP::CloudController::Space.make(organization: org) } - let(:target_space_not_shared_with_route) { VCAP::CloudController::Space.make(organization: org) } - let(:space_to_unshare) { target_space_2 } - let(:unshared_space_guid) { space_to_unshare.guid } - let(:request_body) { {} } - let(:route) do - route = VCAP::CloudController::Route.make(space:) - route.add_shared_space(target_space_1) - route.add_shared_space(target_space_2) - route.add_shared_space(target_space_3) - route - end - let(:guid) { route.guid } - let(:space_dev_headers) do - org.add_user(user) - space.add_developer(user) - headers_for(user) - end - let!(:feature_flag) do - VCAP::CloudController::FeatureFlag.make(name: 'route_sharing', enabled: true, error_message: nil) - end - - before do - org.add_user(user) - target_space_1.add_developer(user) - target_space_2.add_developer(user) - target_space_not_shared_with_route.add_developer(user) - end - - describe 'permissions' do - let(:expected_codes_and_responses) do - h = Hash.new({ code: 403, errors: CF_NOT_AUTHORIZED }.freeze) - - h['org_billing_manager'] = { code: 404 } - h['no_role'] = { code: 404 } - - h['admin'] = { code: 204 } - h['space_developer'] = { code: 204 } - h['space_supporter'] = { code: 204 } - h - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - - context 'when organization is suspended' do - let(:expected_codes_and_responses) do - h = super() - %w[space_developer space_supporter].each { |r| h[r] = { code: 403, errors: CF_ORG_SUSPENDED } } - h - end - - before do - org.update(status: VCAP::CloudController::Organization::SUSPENDED) - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - - context 'when target organization is suspended' do - let(:space_to_unshare) do - space = VCAP::CloudController::Space.make - space.organization.add_user(user) - space.add_developer(user) - space.organization.update(status: VCAP::CloudController::Organization::SUSPENDED) - space - end - - let(:expected_codes_and_responses) do - h = super() - %w[space_developer space_supporter].each do |r| - h[r] = { - code: 422, - errors: [{ - detail: "Unable to unshare route '#{route.uri}' from space '#{space_to_unshare.guid}'. The target organization is suspended.", - title: 'CF-UnprocessableEntity', - code: 10_008 - }] - } - end - h - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - end - - it 'unshares the specified route from the target space and logs audit event' do - expect(route.shared_spaces).to include(target_space_1, space_to_unshare, target_space_3) - - api_call.call(space_dev_headers) - - expect(last_response.status).to eq(204) - - event = VCAP::CloudController::Event.last - expect(event.values).to include({ - type: 'audit.route.unshare', - actor: user.guid, - actee_type: 'route', - actee_name: route.host, - space_guid: space.guid, - organization_guid: space.organization.guid - }) - expect(event.metadata['target_space_guid']).to eq(unshared_space_guid) - - route.reload - expect(route.shared_spaces).to include(target_space_1, target_space_3) - end - - describe 'when route_sharing flag is disabled' do - before do - feature_flag.enabled = false - feature_flag.save - end - - it 'makes users unable to unshare routes' do - api_call.call(space_dev_headers) - - expect(last_response).to have_status_code(403) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => 'Feature Disabled: route_sharing', - 'title' => 'CF-FeatureDisabled', - 'code' => 330_002 - } - ) - ) - end - end - - it 'responds with 204 when the route is not shared with the specified space' do - delete "/v3/routes/#{route.guid}/relationships/shared_spaces/#{target_space_not_shared_with_route.guid}", request_body.to_json, space_dev_headers - - expect(last_response.status).to eq(204) - end - - it "responds with 404 when the route doesn't exist" do - delete "/v3/routes/some-fake-guid/relationships/shared_spaces/#{target_space_1.guid}", request_body.to_json, space_dev_headers - - expect(last_response).to have_status_code(404) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => 'Route not found', - 'title' => 'CF-ResourceNotFound' - } - ) - ) - end - - context 'attempting to unshare from space that owns us' do - let(:space_to_unshare) { space } - - it 'responds with 422 and does not unshare the roue' do - api_call.call(space_dev_headers) - - expect(last_response.status).to eq(422) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => "Unable to unshare route '#{route.uri}' from space " \ - "'#{space.guid}'. Routes cannot be removed from the space that owns them.", - 'title' => 'CF-UnprocessableEntity' - } - ) - ) - - route.reload - expect(route.shared_spaces).to contain_exactly(target_space_1, target_space_2, target_space_3) - end - end - - describe 'target space to unshare with' do - context 'does not exist' do - let(:unshared_space_guid) { 'fake-target' } - - it 'responds with 422' do - api_call.call(space_dev_headers) - - expect(last_response.status).to eq(422) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => "Unable to unshare route '#{route.uri}' from space '#{unshared_space_guid}'. " \ - 'Ensure the space exists and that you have access to it.', - 'title' => 'CF-UnprocessableEntity' - } - ) - ) - end - end - - context 'user does not have read access to the target space' do - let(:unshared_space_guid) { VCAP::CloudController::Space.make(organization: org).guid } - - it 'responds with 422 and does not share the route' do - api_call.call(space_dev_headers) - - expect(last_response.status).to eq(422) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => "Unable to unshare route '#{route.uri}' from space '#{unshared_space_guid}'. " \ - 'Ensure the space exists and that you have access to it.', - 'title' => 'CF-UnprocessableEntity' - } - ) - ) - end - end - - context 'user does not have write access to the target space' do - let(:no_write_access_target_space) { VCAP::CloudController::Space.make(organization: org) } - let(:unshared_space_guid) { no_write_access_target_space.guid } - - before do - no_write_access_target_space.add_auditor(user) - end - - it 'responds with 422 and does not share the route' do - api_call.call(space_dev_headers) - - expect(last_response.status).to eq(422) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => "Unable to unshare route '#{route.uri}' from space '#{no_write_access_target_space.guid}'. " \ - "You don't have write permission for the target space.", - 'title' => 'CF-UnprocessableEntity' - } - ) - ) - end - end - end - end - - describe 'PATCH /v3/routes/:guid/relationships/space' do - let(:shared_domain) { VCAP::CloudController::SharedDomain.make } - let(:route) { VCAP::CloudController::Route.make(space: space, domain: shared_domain) } - let(:api_call) { ->(user_headers) { patch "/v3/routes/#{route.guid}/relationships/space", request_body.to_json, user_headers } } - let(:target_space) { VCAP::CloudController::Space.make(organization: org) } - let(:request_body) do - { - data: { 'guid' => target_space.guid } - } - end - let(:space_dev_headers) do - org.add_user(user) - space.add_developer(user) - headers_for(user) - end - let!(:feature_flag) do - VCAP::CloudController::FeatureFlag.make(name: 'route_sharing', enabled: true, error_message: nil) - end - - before do - org.add_user(user) - target_space.add_developer(user) - end - - context 'when the user logged in' do - let(:expected_codes_and_responses) do - h = Hash.new({ code: 403, errors: CF_NOT_AUTHORIZED }.freeze) - h['admin'] = { code: 200 } - h['no_role'] = { code: 404 } - h['org_billing_manager'] = { code: 404 } - h['space_developer'] = { code: 200 } - h - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - - context 'when organization is suspended' do - let(:expected_codes_and_responses) do - h = super() - %w[space_developer].each { |r| h[r] = { code: 403, errors: CF_ORG_SUSPENDED } } - h - end - - before do - org.update(status: VCAP::CloudController::Organization::SUSPENDED) - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - - context 'when target organization is suspended' do - let(:suspended_space) { VCAP::CloudController::Space.make } - let(:request_body) do - { - data: { 'guid' => suspended_space.guid } - } - end - - let(:expected_codes_and_responses) do - h = super() - %w[space_developer].each do |r| - h[r] = { - code: 422, - errors: [{ - detail: "Unable to transfer owner of route '#{route.uri}' to space '#{suspended_space.guid}'. The target organization is suspended.", - title: 'CF-UnprocessableEntity', - code: 10_008 - }] - } - end - h - end - - before do - suspended_space.organization.add_user(user) - suspended_space.add_developer(user) - suspended_space.organization.update(status: VCAP::CloudController::Organization::SUSPENDED) - end - - it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS - end - end - - it 'changes the route owner to the given space and logs an event', isolation: :truncation do - api_call.call(space_dev_headers) - - expect(last_response.status).to eq(200) - - event = VCAP::CloudController::Event.last - expect(event.values).to include({ - type: 'audit.route.transfer-owner', - actor: user.guid, - actee_type: 'route', - actee_name: route.host, - space_guid: space.guid, - organization_guid: space.organization.guid - }) - expect(event.metadata['target_space_guid']).to eq(target_space.guid) - - route.reload - expect(route.space).to eq target_space - end - - describe 'when using a private domain' do - let(:private_domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: org) } - let(:route) { VCAP::CloudController::Route.make(space: space, domain: private_domain) } - let(:second_org) { VCAP::CloudController::Organization.make } - let(:another_space) { VCAP::CloudController::Space.make(organization: second_org) } - let(:request_body) do - { - data: { 'guid' => another_space.guid } - } - end - let(:space_dev_headers) do - org.add_user(user) - space.add_developer(user) - second_org.add_user(user) - another_space.add_developer(user) - headers_for(user) - end - - it 'responds with 422' do - api_call.call(space_dev_headers) - - expect(last_response.status).to eq(422) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => "Unable to transfer owner of route '#{route.uri}' to space '#{another_space.guid}'. " \ - "Target space does not have access to route's domain", - 'title' => 'CF-UnprocessableEntity' - } - ) - ) - end - end - - describe 'target space to transfer to' do - context 'does not exist' do - let(:target_space_guid) { 'fake-target' } - let(:request_body) do - { - data: { 'guid' => target_space_guid } - } - end - - it 'responds with 422' do - api_call.call(space_dev_headers) - - expect(last_response.status).to eq(422) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => "Unable to transfer owner of route '#{route.uri}' to space '#{target_space_guid}'. " \ - 'Ensure the space exists and that you have access to it.', - 'title' => 'CF-UnprocessableEntity' - } - ) - ) - end - end - - context 'user does not have read access to the target space' do - let(:no_access_target_space) { VCAP::CloudController::Space.make(organization: org) } - let(:request_body) do - { - data: { 'guid' => no_access_target_space.guid } - } - end - - it 'responds with 422 and does not share the route' do - api_call.call(space_dev_headers) - - expect(last_response.status).to eq(422) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => "Unable to transfer owner of route '#{route.uri}' to space '#{no_access_target_space.guid}'. " \ - 'Ensure the space exists and that you have access to it.', - 'title' => 'CF-UnprocessableEntity' - } - ) - ) - end - end - - context 'user does not have write access to the target space' do - let(:no_write_access_target_space) { VCAP::CloudController::Space.make(organization: org) } - let(:request_body) do - { - data: { 'guid' => no_write_access_target_space.guid } - } - end - - before do - no_write_access_target_space.add_auditor(user) - end - - it 'responds with 422 and does not share the route' do - api_call.call(space_dev_headers) - - expect(last_response.status).to eq(422) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => "Unable to transfer owner of route '#{route.uri}' to space '#{no_write_access_target_space.guid}'. " \ - "You don't have write permission for the target space.", - 'title' => 'CF-UnprocessableEntity' - } - ) - ) - end - end - end - - it 'responds with 404 when the route does not exist' do - patch '/v3/routes/some-fake-guid/relationships/space', request_body.to_json, space_dev_headers - - expect(last_response).to have_status_code(404) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => 'Route not found', - 'title' => 'CF-ResourceNotFound' - } - ) - ) - end - - describe 'when the request body is invalid' do - context 'when there are additional keys' do - let(:request_body) do - { - data: { 'guid' => target_space.guid }, - 'fake-key' => 'foo' - } - end - - it 'responds with 422' do - api_call.call(space_dev_headers) - - expect(last_response.status).to eq(422) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => "Unknown field(s): 'fake-key'", - 'title' => 'CF-UnprocessableEntity' - } - ) - ) - end - end - - context 'when data is not a hash' do - let(:request_body) do - { - data: [{ 'guid' => target_space.guid }] - } - end - - it 'responds with 422' do - api_call.call(space_dev_headers) - - expect(last_response.status).to eq(422) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => 'Data must be an object', - 'title' => 'CF-UnprocessableEntity' - } - ) - ) - end - end - end - - describe 'when route_sharing flag is disabled' do - before do - feature_flag.enabled = false - feature_flag.save - end - - it 'makes users unable to transfer-owner' do - api_call.call(space_dev_headers) - - expect(last_response).to have_status_code(403) - expect(parsed_response['errors']).to include( - include( - { - 'detail' => 'Feature Disabled: route_sharing', - 'title' => 'CF-FeatureDisabled', - 'code' => 330_002 - } - ) - ) - end - end - end - - describe 'GET /v3/apps/:app_guid/routes' do - let(:app_model) { VCAP::CloudController::AppModel.make(space:) } - let(:route1) { VCAP::CloudController::Route.make(space:) } - let(:route2) { VCAP::CloudController::Route.make(space:) } - let!(:route3) { VCAP::CloudController::Route.make(space:) } - let!(:route_mapping1) { VCAP::CloudController::RouteMappingModel.make(app: app_model, route: route1, process_type: 'web') } - let!(:route_mapping2) { VCAP::CloudController::RouteMappingModel.make(app: app_model, route: route2, process_type: 'admin') } - let(:api_call) { ->(user_headers) { get "/v3/apps/#{app_model.guid}/routes", nil, user_headers } } - - let(:route1_json) do - { - guid: route1.guid, - protocol: route1.domain.protocols[0], - host: route1.host, - path: route1.path, - port: nil, - url: "#{route1.host}.#{route1.domain.name}#{route1.path}", - created_at: iso8601, - updated_at: iso8601, - destinations: contain_exactly({ - guid: route_mapping1.guid, - app: { - guid: app_model.guid, - process: { - type: route_mapping1.process_type - } - }, - weight: route_mapping1.weight, - port: route_mapping1.presented_port, - protocol: 'http1', - created_at: iso8601, - updated_at: iso8601 - }), - relationships: { - space: { - data: { guid: route1.space.guid } - }, - domain: { - data: { guid: route1.domain.guid } - } - }, - metadata: { - labels: {}, - annotations: {} - }, - links: { - self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{route1.guid}} }, - space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{route1.space.guid}} }, - destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{route1.guid}/destinations} }, - domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{route1.domain.guid}} } - }, - options: {} - } - end - - let(:route2_json) do - { - guid: route2.guid, - protocol: route2.domain.protocols[0], - host: route2.host, - path: route2.path, - port: nil, - url: "#{route2.host}.#{route2.domain.name}#{route2.path}", - created_at: iso8601, - updated_at: iso8601, - destinations: contain_exactly({ - guid: route_mapping2.guid, - app: { - guid: app_model.guid, - process: { - type: route_mapping2.process_type - } - }, - weight: route_mapping2.weight, - port: route_mapping2.presented_port, - protocol: 'http1', - created_at: iso8601, - updated_at: iso8601 - }), - relationships: { - space: { - data: { guid: route2.space.guid } - }, - domain: { - data: { guid: route2.domain.guid } - } - }, - metadata: { - labels: {}, - annotations: {} - }, - links: { - self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{route2.guid}} }, - space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{route2.space.guid}} }, - destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{route2.guid}/destinations} }, - domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{route2.domain.guid}} } - }, - options: {} - } - end - - context 'when the user is a member in the app space' do - let(:expected_codes_and_responses) do - h = Hash.new( - { code: 200, - response_objects: [route1_json, route2_json] }.freeze - ) - - h['org_auditor'] = { code: 404 } - h['org_billing_manager'] = { code: 404 } - h['no_role'] = { code: 404 } - h - end - - it_behaves_like 'permissions for list endpoint', ALL_PERMISSIONS - end - - context 'ports filter' do - # Don't even think of converting the following hash to symbols ('type' => 'tcp' NOT type: 'tcp'), and you need to set the GUID - let(:router_group) { VCAP::CloudController::RoutingApi::RouterGroup.new({ 'type' => 'tcp', 'reservable_ports' => '7777,8888,9999', 'guid' => 'some-guid' }) } - let(:routing_api_client) { instance_double(VCAP::CloudController::RoutingApi::Client) } - - before do - allow_any_instance_of(CloudController::DependencyLocator).to receive(:routing_api_client).and_return(routing_api_client) - allow(routing_api_client).to receive_messages(enabled?: true, router_group: router_group) - end - - context 'when there are multiple TCP routes with different ports' do - # The following `let`s depend on the above `before do` - let(:domain_tcp) { VCAP::CloudController::SharedDomain.make(router_group_guid: router_group.guid, name: 'my.domain') } - let!(:route_with_ports_0) do - VCAP::CloudController::Route.make(host: '', space: space, domain: domain_tcp, guid: 'route-with-port-0', port: 7777) - end - let!(:route_with_ports_1) do - VCAP::CloudController::Route.make(host: '', space: space, domain: domain_tcp, guid: 'route-with-port-1', port: 8888) - end - let!(:route_with_ports_2) do - VCAP::CloudController::Route.make(host: '', space: space, domain: domain_tcp, guid: 'route-with-port-2', port: 9999) - end - let!(:route_mapping_1) { VCAP::CloudController::RouteMappingModel.make(app: app_model, route: route_with_ports_1, process_type: 'web') } - let!(:route_mapping_2) { VCAP::CloudController::RouteMappingModel.make(app: app_model, route: route_with_ports_2, process_type: 'web') } - - it 'returns routes filtered by ports' do - get "/v3/apps/#{app_model.guid}/routes?ports=7777,8888", nil, admin_header - expect(last_response).to have_status_code(200) - expect(parsed_response['resources'].size).to eq(1) - expect(parsed_response['resources'].first['port']).to eq(route_with_ports_1.port) - end - end - end - - describe 'eager loading' do - it 'eager loads associated resources that the presenter specifies' do - expect(VCAP::CloudController::RouteFetcher).to receive(:fetch).with( - anything, - hash_including(eager_loaded_associations: %i[domain space route_mappings labels annotations]) - ).and_call_original - - get "/v3/apps/#{app_model.guid}/routes", nil, admin_header - expect(last_response).to have_status_code(200) - end - end - end -end From 09b217dac67845aa20720f421535c13b1805689c Mon Sep 17 00:00:00 2001 From: johha Date: Mon, 2 Mar 2026 16:18:09 +0100 Subject: [PATCH 12/20] Revert file splits - no benefit without CI parallelization changes Reverts the apps_spec.rb and routes_spec.rb splits since they don't provide performance benefits with the current CI setup. The splits only help when the CI is configured to run spec files in parallel across workers. Focus optimization efforts on: - lightweight_spec_helper/db_spec_helper conversions (reduce load time) - Test data optimization (let! -> let) - Reducing database operations --- spec/request/apps/actions_spec.rb | 664 --- spec/request/apps/builds_and_ssh_spec.rb | 222 - spec/request/apps/create_spec.rb | 451 -- spec/request/apps/delete_and_update_spec.rb | 329 -- spec/request/apps/droplet_spec.rb | 329 -- spec/request/apps/environment_spec.rb | 178 - spec/request/apps/list_spec.rb | 940 ----- spec/request/apps/shared_context.rb | 10 - spec/request/apps/show_spec.rb | 495 --- spec/request/apps_spec.rb | 3542 ++++++++++++++++ spec/request/routes/apps_routes_spec.rb | 173 - spec/request/routes/create_spec.rb | 1290 ------ spec/request/routes/list_spec.rb | 938 ----- spec/request/routes/shared_context.rb | 31 - spec/request/routes/sharing_spec.rb | 918 ---- spec/request/routes/show_spec.rb | 172 - spec/request/routes/update_and_delete_spec.rb | 278 -- spec/request/routes_spec.rb | 3748 +++++++++++++++++ 18 files changed, 7290 insertions(+), 7418 deletions(-) delete mode 100644 spec/request/apps/actions_spec.rb delete mode 100644 spec/request/apps/builds_and_ssh_spec.rb delete mode 100644 spec/request/apps/create_spec.rb delete mode 100644 spec/request/apps/delete_and_update_spec.rb delete mode 100644 spec/request/apps/droplet_spec.rb delete mode 100644 spec/request/apps/environment_spec.rb delete mode 100644 spec/request/apps/list_spec.rb delete mode 100644 spec/request/apps/shared_context.rb delete mode 100644 spec/request/apps/show_spec.rb create mode 100644 spec/request/apps_spec.rb delete mode 100644 spec/request/routes/apps_routes_spec.rb delete mode 100644 spec/request/routes/create_spec.rb delete mode 100644 spec/request/routes/list_spec.rb delete mode 100644 spec/request/routes/shared_context.rb delete mode 100644 spec/request/routes/sharing_spec.rb delete mode 100644 spec/request/routes/show_spec.rb delete mode 100644 spec/request/routes/update_and_delete_spec.rb create mode 100644 spec/request/routes_spec.rb diff --git a/spec/request/apps/actions_spec.rb b/spec/request/apps/actions_spec.rb deleted file mode 100644 index 344dbb5f58..0000000000 --- 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 9fe458d90e..0000000000 --- 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 fea0470cd5..0000000000 --- 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 1758648aee..0000000000 --- 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 8983ff34be..0000000000 --- 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 6d6527a802..0000000000 --- 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 dc20f00fe1..0000000000 --- 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 e2d74fc0a4..0000000000 --- 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 7c81205f9d..0000000000 --- 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 0000000000..64fcef98a7 --- /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 8357d59037..0000000000 --- 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 4d6d709d4c..0000000000 --- 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 4a987141e3..0000000000 --- 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 c634e429f6..0000000000 --- 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 00015c496d..0000000000 --- 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 d566de16b3..0000000000 --- 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 79c60c5ab6..0000000000 --- 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 0000000000..2c2fe30cd8 --- /dev/null +++ b/spec/request/routes_spec.rb @@ -0,0 +1,3748 @@ +require 'spec_helper' +require 'request_spec_shared_examples' +require 'presenters/v3/space_presenter' +require 'presenters/v3/organization_presenter' + +RSpec.describe 'Routes Request' do + let(:user) { VCAP::CloudController::User.make } + let(:admin_header) { admin_headers_for(user) } + let!(:org) { VCAP::CloudController::Organization.make(created_at: 1.hour.ago) } + let!(:space) { VCAP::CloudController::Space.make(name: 'a-space', created_at: 1.hour.ago, organization: org) } + + let(:space_json_generator) do + lambda { |s| + presented_space = VCAP::CloudController::Presenters::V3::SpacePresenter.new(s).to_hash + presented_space[:created_at] = iso8601 + presented_space[:updated_at] = iso8601 + presented_space + } + end + + let(:org_json_generator) do + lambda { |o| + presented_space = VCAP::CloudController::Presenters::V3::OrganizationPresenter.new(o).to_hash + presented_space[:created_at] = iso8601 + presented_space[:updated_at] = iso8601 + presented_space + } + end + + before do + TestConfig.override(kubernetes: {}) + end + + describe 'GET /v3/routes' do + let(:other_space) { VCAP::CloudController::Space.make(name: 'b-space') } + let(:app_model) { VCAP::CloudController::AppModel.make(space:) } + let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } + let!(:route_in_org) do + VCAP::CloudController::Route.make(space: space, domain: domain, host: 'host-1', path: '/path1', guid: 'route-in-org-guid') + end + let!(:route_in_other_org) do + VCAP::CloudController::Route.make(space: other_space, host: 'host-2', path: '/path2', guid: 'route-in-other-org-guid') + end + let!(:route_in_org_dest_web) { VCAP::CloudController::RouteMappingModel.make(app: app_model, route: route_in_org, process_type: 'web') } + let!(:route_in_org_dest_worker) { VCAP::CloudController::RouteMappingModel.make(app: app_model, route: route_in_org, process_type: 'worker') } + let(:api_call) { ->(user_headers) { get '/v3/routes', nil, user_headers } } + let(:route_in_org_json) do + { + guid: route_in_org.guid, + protocol: route_in_org.domain.protocols[0], + host: route_in_org.host, + path: route_in_org.path, + port: nil, + url: "#{route_in_org.host}.#{route_in_org.domain.name}#{route_in_org.path}", + created_at: iso8601, + updated_at: iso8601, + destinations: contain_exactly({ + guid: route_in_org_dest_web.guid, + app: { + guid: app_model.guid, + process: { + type: route_in_org_dest_web.process_type + } + }, + weight: route_in_org_dest_web.weight, + port: route_in_org_dest_web.presented_port, + protocol: 'http1', + created_at: iso8601, + updated_at: iso8601 + }, { + guid: route_in_org_dest_worker.guid, + app: { + guid: app_model.guid, + process: { + type: route_in_org_dest_worker.process_type + } + }, + weight: route_in_org_dest_worker.weight, + port: route_in_org_dest_worker.presented_port, + protocol: 'http1', + created_at: iso8601, + updated_at: iso8601 + }), + relationships: { + space: { + data: { guid: route_in_org.space.guid } + }, + domain: { + data: { guid: route_in_org.domain.guid } + } + }, + metadata: { + labels: {}, + annotations: {} + }, + options: {}, + links: { + self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{route_in_org.guid}} }, + space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{route_in_org.space.guid}} }, + destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{route_in_org.guid}/destinations} }, + domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{route_in_org.domain.guid}} } + } + } + end + + let(:route_in_other_org_json) do + { + guid: route_in_other_org.guid, + protocol: route_in_other_org.domain.protocols[0], + host: route_in_other_org.host, + path: route_in_other_org.path, + port: nil, + url: "#{route_in_other_org.host}.#{route_in_other_org.domain.name}#{route_in_other_org.path}", + created_at: iso8601, + updated_at: iso8601, + destinations: [], + relationships: { + space: { + data: { guid: route_in_other_org.space.guid } + }, + domain: { + data: { guid: route_in_other_org.domain.guid } + } + }, + metadata: { + labels: {}, + annotations: {} + }, + options: {}, + links: { + self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{route_in_other_org.guid}} }, + space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{route_in_other_org.space.guid}} }, + destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{route_in_other_org.guid}/destinations} }, + domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{route_in_other_org.domain.guid}} } + } + } + end + + it_behaves_like 'list_endpoint_with_common_filters' do + let(:resource_klass) { VCAP::CloudController::Route } + let(:api_call) do + ->(headers, filters) { get "/v3/routes?#{filters}", nil, headers } + end + let(:headers) { admin_headers } + end + + describe 'query list parameters' do + it_behaves_like 'list query endpoint' do + let(:request) { 'v3/routes' } + let(:message) { VCAP::CloudController::RoutesListMessage } + let(:user_header) { admin_header } + + let(:params) do + { + page: '2', + per_page: '10', + order_by: 'updated_at', + space_guids: %w[foo bar], + service_instance_guids: %w[baz qux], + organization_guids: %w[foo bar], + domain_guids: %w[foo bar], + app_guids: %w[foo bar], + guids: %w[foo bar], + paths: %w[foo bar], + hosts: 'foo', + ports: 636, + include: 'domain', + label_selector: 'foo,bar', + created_ats: "#{Time.now.utc.iso8601},#{Time.now.utc.iso8601}", + updated_ats: { gt: Time.now.utc.iso8601 } + } + end + end + end + + context 'when the user is a member in the routes org' do + let(:expected_codes_and_responses) do + h = Hash.new( + { code: 200, + response_objects: [route_in_org_json] }.freeze + ) + + h['admin'] = { code: 200, response_objects: [route_in_org_json, route_in_other_org_json] } + h['admin_read_only'] = { code: 200, response_objects: [route_in_org_json, route_in_other_org_json] } + h['global_auditor'] = { code: 200, response_objects: [route_in_org_json, route_in_other_org_json] } + + h['org_billing_manager'] = { code: 200, response_objects: [] } + h['no_role'] = { code: 200, response_objects: [] } + h + end + + it_behaves_like 'permissions for list endpoint', ALL_PERMISSIONS + end + + describe 'includes' do + context 'when including domains' do + let(:domain1) { VCAP::CloudController::SharedDomain.make(name: 'first-domain.example.com') } + let(:domain1_json) do + { + guid: domain1.guid, + created_at: iso8601, + updated_at: iso8601, + name: domain1.name, + internal: false, + router_group: nil, + supported_protocols: ['http'], + metadata: { + labels: {}, + annotations: {} + }, + relationships: { + organization: { + data: nil + }, + shared_organizations: { + data: [] + } + }, + links: { + self: { href: "#{link_prefix}/v3/domains/#{domain1.guid}" }, + route_reservations: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain1.guid}/route_reservations} } + } + } + end + + let(:domain2) { VCAP::CloudController::SharedDomain.make(name: 'second-domain.example.com') } + let(:domain2_json) do + { + guid: domain2.guid, + created_at: iso8601, + updated_at: iso8601, + name: domain2.name, + internal: false, + router_group: nil, + supported_protocols: ['http'], + metadata: { + labels: {}, + annotations: {} + }, + relationships: { + organization: { + data: nil + }, + shared_organizations: { + data: [] + } + }, + links: { + self: { href: "#{link_prefix}/v3/domains/#{domain1.guid}" }, + route_reservations: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain1.guid}/route_reservations} } + } + } + end + + let(:domain2) { VCAP::CloudController::SharedDomain.make(name: 'second-domain.example.com') } + let(:domain2_json) do + { + guid: domain2.guid, + created_at: iso8601, + updated_at: iso8601, + name: domain2.name, + internal: false, + router_group: nil, + supported_protocols: ['http'], + metadata: { + labels: {}, + annotations: {} + }, + relationships: { + organization: { + data: nil + }, + shared_organizations: { + data: [] + } + }, + links: { + self: { href: "#{link_prefix}/v3/domains/#{domain2.guid}" }, + route_reservations: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain2.guid}/route_reservations} } + } + } + end + + let!(:route1_domain1) do + VCAP::CloudController::Route.make(space: space, host: 'route1', domain: domain1, path: '/path1', guid: 'route1-guid') + end + let(:route1_domain1_json) do + { + guid: route1_domain1.guid, + protocol: route1_domain1.domain.protocols[0], + created_at: iso8601, + updated_at: iso8601, + host: route1_domain1.host, + path: route1_domain1.path, + port: nil, + url: "#{route1_domain1.host}.#{domain1.name}#{route1_domain1.path}", + destinations: [], + metadata: { + labels: {}, + annotations: {} + }, + relationships: { + space: { + data: { + guid: space.guid + } + }, + domain: { + data: { + guid: domain1.guid + } + } + }, + options: {}, + links: { + self: { href: "http://api2.vcap.me/v3/routes/#{route1_domain1.guid}" }, + space: { href: "http://api2.vcap.me/v3/spaces/#{space.guid}" }, + destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{route1_domain1.guid}/destinations} }, + domain: { href: "http://api2.vcap.me/v3/domains/#{domain1.guid}" } + } + } + end + + let!(:route_in_org) do + VCAP::CloudController::Route.make(space: space, domain: domain1, host: 'host-1', path: '/path1', guid: 'route-in-org-guid') + end + let!(:route_in_other_org) do + VCAP::CloudController::Route.make(space: other_space, domain: domain2, host: 'host-2', path: '/path2', guid: 'route-in-other-org-guid') + end + + it 'includes the unique domains for the routes' do + get '/v3/routes?include=domain', nil, admin_header + expect(last_response).to have_status_code(200) + expect({ + resources: parsed_response['resources'], + included: parsed_response['included'] + }).to match_json_response({ + resources: [route_in_org_json, route_in_other_org_json, route1_domain1_json], + included: { 'domains' => [domain1_json, domain2_json] } + }) + end + end + + context 'when including spaces and orgs' do + it 'includes the unique spaces and organizations for the routes' do + get '/v3/routes?include=space,space.organization', nil, admin_header + expect(last_response).to have_status_code(200) + expect({ + resources: parsed_response['resources'], + included: parsed_response['included'] + }).to match_json_response({ + resources: [route_in_org_json, route_in_other_org_json], + included: { + 'spaces' => [ + space_json_generator.call(space), + space_json_generator.call(other_space) + ], + 'organizations' => [ + org_json_generator.call(org), + org_json_generator.call(other_space.organization) + ] + } + }) + end + end + + context 'when including spaces' do + it 'eagerly loads spaces to efficiently access space_guid' do + expect(VCAP::CloudController::IncludeSpaceDecorator).to receive(:decorate) do |_, resources| + expect(resources).not_to be_empty + resources.each { |r| expect(r.associations).to include(:space) } + end + + get '/v3/routes?include=space', nil, admin_header + expect(last_response).to have_status_code(200) + end + end + + context 'when including orgs' do + it 'eagerly loads spaces to efficiently access space.organization_id' do + expect(VCAP::CloudController::IncludeOrganizationDecorator).to receive(:decorate) do |_, resources| + expect(resources).not_to be_empty + resources.each { |r| expect(r.associations).to include(:space) } + end + + get '/v3/routes?include=space.organization', nil, admin_header + expect(last_response).to have_status_code(200) + end + end + end + + describe 'filters' do + let!(:route_without_host_and_with_path) do + VCAP::CloudController::Route.make(space: space, host: '', domain: domain, path: '/path1', guid: 'route-without-host') + end + let!(:route_without_host_and_with_path2) do + VCAP::CloudController::Route.make(space: space, host: '', domain: domain, path: '/path2', guid: 'route-without-host2') + end + let(:route_without_host_and_with_path_json) do + { + guid: 'route-without-host', + protocol: domain.protocols[0], + created_at: iso8601, + updated_at: iso8601, + destinations: [], + host: '', + path: '/path1', + port: nil, + url: "#{domain.name}/path1", + metadata: { + labels: {}, + annotations: {} + }, + relationships: { + space: { + data: { + guid: space.guid + } + }, + domain: { + data: { + guid: domain.guid + } + } + }, + options: {}, + links: { + self: { href: 'http://api2.vcap.me/v3/routes/route-without-host' }, + space: { href: "http://api2.vcap.me/v3/spaces/#{space.guid}" }, + destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/route-without-host/destinations} }, + domain: { href: "http://api2.vcap.me/v3/domains/#{domain.guid}" } + } + } + end + let(:route_without_host_and_with_path2_json) do + { + guid: 'route-without-host2', + protocol: domain.protocols[0], + created_at: iso8601, + updated_at: iso8601, + destinations: [], + host: '', + path: '/path2', + port: nil, + url: "#{domain.name}/path2", + metadata: { + labels: {}, + annotations: {} + }, + relationships: { + space: { + data: { + guid: space.guid + } + }, + domain: { + data: { + guid: domain.guid + } + } + }, + options: {}, + links: { + self: { href: 'http://api2.vcap.me/v3/routes/route-without-host2' }, + space: { href: "http://api2.vcap.me/v3/spaces/#{space.guid}" }, + destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/route-without-host2/destinations} }, + domain: { href: "http://api2.vcap.me/v3/domains/#{domain.guid}" } + } + } + end + let!(:route_without_path_and_with_host) do + VCAP::CloudController::Route.make(space: space, host: 'host-1', domain: domain, path: '', guid: 'route-without-path') + end + let(:route_without_path_and_with_host_json) do + { + guid: 'route-without-path', + protocol: domain.protocols[0], + created_at: iso8601, + updated_at: iso8601, + destinations: [], + host: 'host-1', + path: '', + port: nil, + url: "host-1.#{domain.name}", + metadata: { + labels: {}, + annotations: {} + }, + relationships: { + space: { + data: { + guid: space.guid + } + }, + domain: { + data: { + guid: domain.guid + } + } + }, + options: {}, + links: { + self: { href: 'http://api2.vcap.me/v3/routes/route-without-path' }, + space: { href: "http://api2.vcap.me/v3/spaces/#{space.guid}" }, + destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/route-without-path/destinations} }, + domain: { href: "http://api2.vcap.me/v3/domains/#{domain.guid}" } + } + } + end + + context 'hosts filter' do + it 'returns routes filtered by host' do + get '/v3/routes?hosts=host-1', nil, admin_header + expect(last_response).to have_status_code(200) + expect({ + resources: parsed_response['resources'] + }).to match_json_response({ + resources: [route_in_org_json, route_without_path_and_with_host_json] + }) + end + + it 'returns route with no host if one exists when filtering by empty host' do + get '/v3/routes?hosts=', nil, admin_header + expect(last_response).to have_status_code(200) + expect({ + resources: parsed_response['resources'] + }).to match_json_response({ + resources: [route_without_host_and_with_path_json, route_without_host_and_with_path2_json] + }) + end + end + + context 'paths filter' do + it 'returns routes filtered by path' do + get '/v3/routes?paths=%2Fpath1', nil, admin_header + expect(last_response).to have_status_code(200) + expect({ + resources: parsed_response['resources'] + }).to match_json_response({ + resources: [route_in_org_json, route_without_host_and_with_path_json] + }) + end + + it 'returns route with no path when filtering by empty path' do + get '/v3/routes?paths=', nil, admin_header + expect(last_response).to have_status_code(200) + expect({ + resources: parsed_response['resources'] + }).to match_json_response({ + resources: [route_without_path_and_with_host_json] + }) + end + end + + context 'hosts and paths filter' do + it 'returns routes with no host and the provided path when host is empty' do + get '/v3/routes?paths=%2Fpath1&hosts=', nil, admin_header + expect(last_response).to have_status_code(200) + expect({ + resources: parsed_response['resources'] + }).to match_json_response({ + resources: [route_without_host_and_with_path_json] + }) + end + end + + context 'organization_guids filter' do + it 'returns routes filtered by organization_guid' do + get "/v3/routes?organization_guids=#{other_space.organization.guid}", nil, admin_header + expect(last_response).to have_status_code(200) + expect({ + resources: parsed_response['resources'] + }).to match_json_response({ + resources: [route_in_other_org_json] + }) + end + end + + context 'space_guids filter' do + it 'returns routes filtered by space_guid' do + get "/v3/routes?space_guids=#{other_space.guid}", nil, admin_header + expect(last_response).to have_status_code(200) + expect({ + resources: parsed_response['resources'] + }).to match_json_response({ + resources: [route_in_other_org_json] + }) + end + end + + context 'domain_guids filter' do + it 'returns routes filtered by domain_guid' do + get "/v3/routes?domain_guids=#{route_in_other_org.domain.guid}", nil, admin_header + expect(last_response).to have_status_code(200) + expect({ + resources: parsed_response['resources'] + }).to match_json_response({ + resources: [route_in_other_org_json] + }) + end + end + + context 'app_guids filter' do + it 'returns routes filtered by app_guid' do + get "/v3/routes?app_guids=#{app_model.guid}", nil, admin_header + expect(last_response).to have_status_code(200) + expect(parsed_response['resources'].size).to eq(1) + expect(parsed_response['resources'].first['destinations'].size).to eq(2) + expect( + parsed_response['resources'].first['destinations'].map { |destination| destination['app']['guid'] }.uniq + ).to eq([app_model.guid]) + end + end + + context 'ports filter' do + # Don't even think of converting the following hash to symbols ('type' => 'tcp' NOT type: 'tcp'), and you need to set the GUID + let(:router_group) { VCAP::CloudController::RoutingApi::RouterGroup.new({ 'type' => 'tcp', 'reservable_ports' => '7777,8888,9999', 'guid' => 'some-guid' }) } + let(:routing_api_client) { instance_double(VCAP::CloudController::RoutingApi::Client) } + + before do + allow_any_instance_of(CloudController::DependencyLocator).to receive(:routing_api_client).and_return(routing_api_client) + allow(routing_api_client).to receive_messages(enabled?: true, router_group: router_group) + end + + context 'when there are multiple TCP routes with different ports' do + # The following `let`s depend on the above `before do` + let(:domain_tcp) { VCAP::CloudController::SharedDomain.make(router_group_guid: router_group.guid, name: 'my.domain') } + let!(:route_with_ports_0) do + VCAP::CloudController::Route.make(host: '', space: space, domain: domain_tcp, guid: 'route-with-port-0', port: 7777) + end + let!(:route_with_ports_1) do + VCAP::CloudController::Route.make(host: '', space: space, domain: domain_tcp, guid: 'route-with-port-1', port: 8888) + end + let!(:route_with_ports_2) do + VCAP::CloudController::Route.make(host: '', space: space, domain: domain_tcp, guid: 'route-with-port-2', port: 9999) + end + + it 'returns routes filtered by ports' do + get '/v3/routes?ports=7777,8888', nil, admin_header + expect(last_response).to have_status_code(200) + expect(parsed_response['resources'].size).to eq(2) + expect(parsed_response['resources'].pluck('port')).to contain_exactly(route_with_ports_0.port, route_with_ports_1.port) + end + end + end + + context 'service instance guids filter' do + let(:service_instance_one) do + VCAP::CloudController::ManagedServiceInstance.make(:routing, space: space, name: 'si-name-1') + end + let(:service_instance_two) do + VCAP::CloudController::ManagedServiceInstance.make(:routing, space: space, name: 'si-name-2') + end + + let!(:route_with_service_instance_one) do + VCAP::CloudController::Route.make(space: space, host: 'host-with-service-instance-one', domain: domain, path: '/path1', guid: 'route-with-service-instance-one') + end + let!(:route_with_service_instance_two) do + VCAP::CloudController::Route.make(space: space, host: 'host-with-service-instance-two', domain: domain, path: '/path2', guid: 'route-with-service-instance-two') + end + + let!(:route_mapping_one) { VCAP::CloudController::RouteBinding.make(route: route_with_service_instance_one, service_instance: service_instance_one) } + let!(:route_mapping_two) { VCAP::CloudController::RouteBinding.make(route: route_with_service_instance_two, service_instance: service_instance_two) } + + it 'returns routes filtered by service instance guid' do + get "/v3/routes?service_instance_guids=#{service_instance_one.guid},#{service_instance_two.guid}", nil, admin_header + expect(last_response).to have_status_code(200) + expect(parsed_response['resources'].size).to eq(2) + expect(parsed_response['resources'].pluck('guid')).to contain_exactly('route-with-service-instance-one', 'route-with-service-instance-two') + end + end + end + + describe 'labels' do + let!(:domain1) { VCAP::CloudController::PrivateDomain.make(name: 'dom1.com', owning_organization: org) } + let!(:route1) { VCAP::CloudController::Route.make(space: space, domain: domain1, host: 'hall', path: '/oates', guid: 'guid-1') } + let!(:route1_label) { VCAP::CloudController::RouteLabelModel.make(resource_guid: route1.guid, key_name: 'animal', value: 'dog') } + + let!(:domain2) { VCAP::CloudController::PrivateDomain.make(name: 'dom2.com', owning_organization: org) } + let!(:route2) { VCAP::CloudController::Route.make(space: space, domain: domain2, guid: 'guid-2') } + let!(:route2_label) { VCAP::CloudController::RouteLabelModel.make(resource_guid: route2.guid, key_name: 'animal', value: 'cow') } + let!(:route2__exclusive_label) { VCAP::CloudController::RouteLabelModel.make(resource_guid: route2.guid, key_name: 'santa', value: 'claus') } + + describe 'label_selectors' do + it 'returns a 200 and the filtered routes for "in" label selector' do + get '/v3/routes?label_selector=animal in (dog)', nil, admin_header + + expect(last_response).to have_status_code(200), last_response.body + parsed_response = Oj.load(last_response.body) + + expected_pagination = { + 'total_results' => 1, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal+in+%28dog%29&page=1&per_page=50" }, + 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal+in+%28dog%29&page=1&per_page=50" }, + 'next' => nil, + 'previous' => nil + } + + expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route1.guid) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + + it 'returns a 200 and the filtered routes for "in" label selector with space guids' do + get "/v3/routes?label_selector=animal in (dog)&space_guids=#{space.guid}", nil, admin_header + + expect(last_response).to have_status_code(200) + parsed_response = Oj.load(last_response.body) + + expected_pagination = { + 'total_results' => 1, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal+in+%28dog%29&page=1&per_page=50&space_guids=#{space.guid}" }, + 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal+in+%28dog%29&page=1&per_page=50&space_guids=#{space.guid}" }, + 'next' => nil, + 'previous' => nil + } + + expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route1.guid) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + + it 'returns a 200 and the filtered routes for "in" label selector with org filters' do + get "/v3/routes?label_selector=animal in (dog)&organization_guids=#{org.guid}", nil, admin_header + + expect(last_response).to have_status_code(200), last_response.body + parsed_response = Oj.load(last_response.body) + + expected_pagination = { + 'total_results' => 1, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal+in+%28dog%29&organization_guids=#{org.guid}&page=1&per_page=50" }, + 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal+in+%28dog%29&organization_guids=#{org.guid}&page=1&per_page=50" }, + 'next' => nil, + 'previous' => nil + } + + expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route1.guid) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + + it 'returns a 200 and the filtered routes for "in" label selector with domain filters' do + get "/v3/routes?label_selector=animal in (dog)&domain_guids=#{domain1.guid}", nil, admin_header + + expect(last_response).to have_status_code(200), last_response.body + parsed_response = Oj.load(last_response.body) + + expected_pagination = { + 'total_results' => 1, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/routes?domain_guids=#{domain1.guid}&label_selector=animal+in+%28dog%29&page=1&per_page=50" }, + 'last' => { 'href' => "#{link_prefix}/v3/routes?domain_guids=#{domain1.guid}&label_selector=animal+in+%28dog%29&page=1&per_page=50" }, + 'next' => nil, + 'previous' => nil + } + + expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route1.guid) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + + it 'returns a 200 and the filtered routes for "in" label selector with host filters' do + get '/v3/routes?label_selector=animal in (dog)&hosts=hall', nil, admin_header + + expect(last_response).to have_status_code(200), last_response.body + parsed_response = Oj.load(last_response.body) + + expected_pagination = { + 'total_results' => 1, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/routes?hosts=hall&label_selector=animal+in+%28dog%29&page=1&per_page=50" }, + 'last' => { 'href' => "#{link_prefix}/v3/routes?hosts=hall&label_selector=animal+in+%28dog%29&page=1&per_page=50" }, + 'next' => nil, + 'previous' => nil + } + + expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route1.guid) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + + it 'returns a 200 and the filtered routes for "in" label selector with path filters' do + get '/v3/routes?label_selector=animal in (dog)&paths=/oates', nil, admin_header + + expect(last_response).to have_status_code(200) + parsed_response = Oj.load(last_response.body) + + expected_pagination = { + 'total_results' => 1, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal+in+%28dog%29&page=1&paths=%2Foates&per_page=50" }, + 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal+in+%28dog%29&page=1&paths=%2Foates&per_page=50" }, + 'next' => nil, + 'previous' => nil + } + + expect(last_response).to have_status_code(200) + expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route1.guid) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + end + + it 'returns a 200 and the filtered routes for "notin" label selector' do + get '/v3/routes?label_selector=animal notin (dog)', nil, admin_header + + parsed_response = Oj.load(last_response.body) + + expected_pagination = { + 'total_results' => 3, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal+notin+%28dog%29&page=1&per_page=50" }, + 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal+notin+%28dog%29&page=1&per_page=50" }, + 'next' => nil, + 'previous' => nil + } + + expect(last_response).to have_status_code(200) + expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route2.guid, route_in_org.guid, route_in_other_org.guid) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + + it 'returns a 200 and the filtered routes for "=" label selector' do + get '/v3/routes?label_selector=animal=dog', nil, admin_header + + parsed_response = Oj.load(last_response.body) + + expected_pagination = { + 'total_results' => 1, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal%3Ddog&page=1&per_page=50" }, + 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal%3Ddog&page=1&per_page=50" }, + 'next' => nil, + 'previous' => nil + } + + expect(last_response).to have_status_code(200) + expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route1.guid) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + + it 'returns a 200 and the filtered domains for "==" label selector' do + get '/v3/routes?label_selector=animal==dog', nil, admin_header + + parsed_response = Oj.load(last_response.body) + + expected_pagination = { + 'total_results' => 1, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal%3D%3Ddog&page=1&per_page=50" }, + 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal%3D%3Ddog&page=1&per_page=50" }, + 'next' => nil, + 'previous' => nil + } + + expect(last_response).to have_status_code(200) + expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route1.guid) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + + it 'returns a 200 and the filtered routes for "!=" label selector' do + get '/v3/routes?label_selector=animal!=dog', nil, admin_header + + parsed_response = Oj.load(last_response.body) + + expected_pagination = { + 'total_results' => 3, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal%21%3Ddog&page=1&per_page=50" }, + 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal%21%3Ddog&page=1&per_page=50" }, + 'next' => nil, + 'previous' => nil + } + + expect(last_response).to have_status_code(200) + expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route2.guid, route_in_org.guid, route_in_other_org.guid) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + + it 'returns a 200 and the filtered routes for "=" label selector' do + get '/v3/routes?label_selector=animal=cow,santa=claus', nil, admin_header + + parsed_response = Oj.load(last_response.body) + + expected_pagination = { + 'total_results' => 1, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal%3Dcow%2Csanta%3Dclaus&page=1&per_page=50" }, + 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=animal%3Dcow%2Csanta%3Dclaus&page=1&per_page=50" }, + 'next' => nil, + 'previous' => nil + } + + expect(last_response).to have_status_code(200) + expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route2.guid) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + + it 'returns a 200 and the filtered routes for existence label selector' do + get '/v3/routes?label_selector=santa', nil, admin_header + + parsed_response = Oj.load(last_response.body) + + expected_pagination = { + 'total_results' => 1, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=santa&page=1&per_page=50" }, + 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=santa&page=1&per_page=50" }, + 'next' => nil, + 'previous' => nil + } + + expect(last_response).to have_status_code(200) + expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route2.guid) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + + it 'returns a 200 and the filtered routes for non-existence label selector' do + get '/v3/routes?label_selector=!santa', nil, admin_header + + parsed_response = Oj.load(last_response.body) + + expected_pagination = { + 'total_results' => 3, + 'total_pages' => 1, + 'first' => { 'href' => "#{link_prefix}/v3/routes?label_selector=%21santa&page=1&per_page=50" }, + 'last' => { 'href' => "#{link_prefix}/v3/routes?label_selector=%21santa&page=1&per_page=50" }, + 'next' => nil, + 'previous' => nil + } + + expect(last_response).to have_status_code(200) + expect(parsed_response['resources'].pluck('guid')).to contain_exactly(route1.guid, route_in_org.guid, route_in_other_org.guid) + expect(parsed_response['pagination']).to eq(expected_pagination) + end + end + + describe 'eager loading' do + it 'eager loads associated resources that the presenter specifies' do + expect(VCAP::CloudController::RouteFetcher).to receive(:fetch).with( + anything, + hash_including(eager_loaded_associations: %i[domain space route_mappings labels annotations]) + ).and_call_original + + get '/v3/routes', nil, admin_header + expect(last_response).to have_status_code(200) + end + end + + context 'when the request is invalid' do + it 'returns 400 with a meaningful error' do + get '/v3/routes?page=potato', nil, admin_header + expect(last_response).to have_status_code(400) + expect(last_response).to have_error_message('The query parameter is invalid: Page must be a positive integer') + end + end + + context 'when the user is not logged in' do + it 'returns 401 for Unauthenticated requests' do + get '/v3/routes', nil, base_json_headers + expect(last_response).to have_status_code(401) + end + end + end + + describe 'GET /v3/routes/:guid' do + let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } + let(:route) { VCAP::CloudController::Route.make(space:, domain:) } + let(:api_call) { ->(user_headers) { get "/v3/routes/#{route.guid}", nil, user_headers } } + let(:route_json) do + { + guid: route.guid, + protocol: route.domain.protocols[0], + host: route.host, + path: route.path, + port: nil, + url: "#{route.host}.#{route.domain.name}#{route.path}", + created_at: iso8601, + updated_at: iso8601, + destinations: [], + relationships: { + space: { + data: { guid: route.space.guid } + }, + domain: { + data: { guid: route.domain.guid } + } + }, + metadata: { + labels: {}, + annotations: {} + }, + options: {}, + links: { + self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, + space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{route.space.guid}} }, + destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, + domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{route.domain.guid}} } + } + } + end + + context 'when the user is a member in the routes org' do + let(:expected_codes_and_responses) do + h = Hash.new( + { code: 200, + response_object: route_json }.freeze + ) + + h['org_billing_manager'] = { code: 404 } + h['no_role'] = { code: 404 } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + + describe 'when the user is not logged in' do + it 'returns 401 for Unauthenticated requests' do + get "/v3/routes/#{route.guid}", nil, base_json_headers + expect(last_response).to have_status_code(401) + end + end + + describe 'includes' do + context 'when including domains' do + let(:domain_json) do + { + guid: domain.guid, + created_at: iso8601, + updated_at: iso8601, + name: domain.name, + internal: false, + router_group: nil, + supported_protocols: ['http'], + metadata: { + labels: {}, + annotations: {} + }, + relationships: { + organization: { + data: { guid: domain.owning_organization.guid } + }, + shared_organizations: { + data: [] + } + }, + links: { + self: { href: "#{link_prefix}/v3/domains/#{domain.guid}" }, + organization: { href: %r{#{Regexp.escape(link_prefix)}/v3/organizations/#{domain.owning_organization.guid}} }, + route_reservations: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}/route_reservations} }, + shared_organizations: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}/relationships/shared_organizations} } + } + } + end + let(:route_json) do + { + guid: route.guid, + protocol: route.domain.protocols[0], + host: route.host, + path: route.path, + port: nil, + url: "#{route.host}.#{route.domain.name}#{route.path}", + created_at: iso8601, + updated_at: iso8601, + destinations: [], + relationships: { + space: { + data: { guid: route.space.guid } + }, + domain: { + data: { guid: route.domain.guid } + } + }, + metadata: { + labels: {}, + annotations: {} + }, + options: {}, + links: { + self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, + space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{route.space.guid}} }, + destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, + domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{route.domain.guid}} } + }, + included: { domains: [domain_json] } + } + end + + it 'includes the domain for the route' do + get "/v3/routes/#{route.guid}?include=domain", nil, admin_header + expect(last_response).to have_status_code(200), last_response.body + expect(parsed_response).to match_json_response(route_json) + end + end + + context 'when including spaces and orgs' do + it 'includes the unique spaces and organizations for the routes' do + get "/v3/routes/#{route.guid}?include=space,space.organization", nil, admin_header + expect(last_response).to have_status_code(200) + expect(parsed_response['included']).to match_json_response( + 'spaces' => [ + space_json_generator.call(space) + ], + 'organizations' => [ + org_json_generator.call(org) + ] + ) + end + + context 'user is org_auditor' do + let(:user_header) { set_user_with_header_as_role(user: user, role: 'org_auditor', org: org) } + + it 'includes the unique organizations for the routes, but no spaces' do + get "/v3/routes/#{route.guid}?include=space,space.organization", nil, user_header + expect(last_response).to have_status_code(200) + expect(parsed_response['included']).to match_json_response( + 'spaces' => [], + 'organizations' => [ + org_json_generator.call(org) + ] + ) + end + end + end + end + end + + describe 'POST /v3/routes' do + context 'when creating a route in a tcp domain' do + let(:domain) { VCAP::CloudController::SharedDomain.make(router_group_guid: 'some-router-group', router_group_type: 'tcp') } + + before do + token = { token_type: 'Bearer', access_token: 'my-favourite-access-token' } + stub_request(:post, 'https://uaa.service.cf.internal/oauth/token'). + to_return(status: 200, body: token.to_json, headers: { 'Content-Type' => 'application/json' }) + stub_request(:get, 'http://localhost:3000/routing/v1/router_groups'). + to_return(status: 200, body: '[{"guid":"some-router-group","name":"Robby Router","type":"tcp","reservable_ports":"25555"}]', headers: {}) + end + + context 'and the route has a host' do + let(:params) do + { + host: 'my-host', + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + it 'returns a 422 and a helpful error message' do + post '/v3/routes', params.to_json, admin_header + expect(last_response).to have_status_code(422) + expect(last_response).to have_error_message('Hosts are not supported for TCP routes.') + end + end + + context 'and the route has a path' do + let(:params) do + { + path: '/cgi-bin', + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + it 'returns a 422 and a helpful error message' do + post '/v3/routes', params.to_json, admin_header + expect(last_response).to have_status_code(422) + expect(last_response).to have_error_message('Paths are not supported for TCP routes.') + end + end + end + + context 'when creating a route in a scoped domain' do + let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } + + describe 'when creating a route without a host' do + let(:params) do + { + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + let(:route_json) do + { + guid: UUID_REGEX, + protocol: domain.protocols[0], + host: '', + path: '', + port: nil, + url: domain.name, + created_at: iso8601, + updated_at: iso8601, + destinations: [], + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + }, + links: { + self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, + space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}} }, + destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, + domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } + }, + metadata: { + labels: {}, + annotations: {} + }, + options: {} + } + end + + describe 'valid routes' do + let(:api_call) { ->(user_headers) { post '/v3/routes', params.to_json, user_headers } } + + let(:expected_codes_and_responses) do + h = Hash.new( + { code: 403 }.freeze + ) + h['admin'] = { + code: 201, + response_object: route_json + } + h['space_developer'] = { + code: 201, + response_object: route_json + } + h['space_supporter'] = { + code: 201, + response_object: route_json + } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + end + + describe 'when creating a route with a host' do + let(:params) do + { + host: 'some-host', + path: '/some-path', + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + }, + metadata: { + labels: { potato: 'yam' }, + annotations: { style: 'mashed' } + }, + options: {} + } + end + + let(:route_json) do + { + guid: UUID_REGEX, + protocol: domain.protocols[0], + host: 'some-host', + path: '/some-path', + port: nil, + url: "some-host.#{domain.name}/some-path", + created_at: iso8601, + updated_at: iso8601, + destinations: [], + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + }, + links: { + self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, + space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}} }, + destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, + domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } + }, + metadata: { + labels: { potato: 'yam' }, + annotations: { style: 'mashed' } + }, + options: {} + } + end + + describe 'valid routes' do + it_behaves_like 'permissions for single object endpoint', ['admin'] do + let(:api_call) { ->(user_headers) { post '/v3/routes', params.to_json, user_headers } } + + let(:expected_codes_and_responses) do + h = Hash.new( + { code: 403 }.freeze + ) + h['admin'] = { + code: 201, + response_object: route_json + } + h['space_developer'] = { + code: 201, + response_object: route_json + } + h['space_supporter'] = { + code: 201, + response_object: route_json + } + h + end + + let(:expected_event_hash) do + { + type: 'audit.route.create', + actee: parsed_response['guid'], + actee_type: 'route', + actee_name: 'some-host', + metadata: { request: params }.to_json, + space_guid: space.guid, + organization_guid: org.guid + } + end + end + end + end + + describe 'when creating a route with a wildcard host' do + let(:params) do + { + host: '*', + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + let(:route_json) do + { + guid: UUID_REGEX, + protocol: domain.protocols[0], + host: '*', + path: '', + port: nil, + url: "*.#{domain.name}", + created_at: iso8601, + updated_at: iso8601, + destinations: [], + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + }, + links: { + self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, + space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}} }, + destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, + domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } + }, + metadata: { + labels: {}, + annotations: {} + }, + options: {} + } + end + + describe 'valid routes' do + let(:api_call) { ->(user_headers) { post '/v3/routes', params.to_json, user_headers } } + + let(:expected_codes_and_responses) do + h = Hash.new( + { code: 403 }.freeze + ) + h['admin'] = { + code: 201, + response_object: route_json + } + h['space_developer'] = { + code: 201, + response_object: route_json + } + h['space_supporter'] = { + code: 201, + response_object: route_json + } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + end + end + + context 'when creating a route in an unscoped domain' do + let(:domain) { VCAP::CloudController::SharedDomain.make } + + describe 'when creating a route without a host' do + let(:params) do + { + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + it 'fails with a helpful message' do + post '/v3/routes', params.to_json, admin_header + expect(last_response).to have_status_code(422) + expect(last_response).to have_error_message('Missing host. Routes in shared domains must have a host defined.') + end + end + + describe 'when creating a route with a host' do + let(:params) do + { + host: 'some-host', + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + let(:route_json) do + { + guid: UUID_REGEX, + protocol: domain.protocols[0], + host: 'some-host', + path: '', + port: nil, + url: "some-host.#{domain.name}", + created_at: iso8601, + updated_at: iso8601, + destinations: [], + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + }, + links: { + self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, + space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}} }, + destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, + domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } + }, + metadata: { + labels: {}, + annotations: {} + }, + options: {} + } + end + + describe 'valid routes' do + let(:api_call) { ->(user_headers) { post '/v3/routes', params.to_json, user_headers } } + + let(:expected_codes_and_responses) do + h = Hash.new( + { code: 403 }.freeze + ) + h['admin'] = { + code: 201, + response_object: route_json + } + h['space_developer'] = { + code: 201, + response_object: route_json + } + h['space_supporter'] = { + code: 201, + response_object: route_json + } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + end + + describe 'when creating a route with a wildcard host' do + let(:params) do + { + host: '*', + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + let(:route_json) do + { + guid: UUID_REGEX, + protocol: domain.protocols[0], + host: '*', + path: '', + port: nil, + url: "*.#{domain.name}", + created_at: iso8601, + updated_at: iso8601, + destinations: [], + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + }, + links: { + self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, + space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}} }, + destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, + domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } + }, + metadata: { + labels: {}, + annotations: {} + }, + options: {} + } + end + + describe 'valid routes' do + let(:api_call) { ->(user_headers) { post '/v3/routes', params.to_json, user_headers } } + + let(:expected_codes_and_responses) do + h = Hash.new( + { code: 403 }.freeze + ) + h['admin'] = { + code: 201, + response_object: route_json + } + h['space_developer'] = { + code: 422 + } + h['space_supporter'] = { + code: 422 + } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + end + + describe 'the domain supports tcp routes' do + let(:router_group) { VCAP::CloudController::RoutingApi::RouterGroup.new({ 'type' => 'tcp', 'reservable_ports' => '123' }) } + let(:domain) { VCAP::CloudController::SharedDomain.make(router_group_guid: 'some-router-group', name: 'my.domain') } + let(:routing_api_client) { instance_double(VCAP::CloudController::RoutingApi::Client) } + + before do + TestConfig.override( + kubernetes: { host_url: nil }, + external_domain: 'api2.vcap.me', + external_protocol: 'https' + ) + allow_any_instance_of(CloudController::DependencyLocator).to receive(:routing_api_client).and_return(routing_api_client) + allow(routing_api_client).to receive_messages(enabled?: true, router_group: router_group) + end + + let(:params) do + { + port: 123, + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + let(:api_call) { ->(user_headers) { post '/v3/routes', params.to_json, user_headers } } + + let(:route_json) do + { + guid: UUID_REGEX, + port: 123, + host: '', + path: '', + protocol: 'tcp', + url: "#{domain.name}:123", + created_at: iso8601, + updated_at: iso8601, + destinations: [], + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + }, + links: { + self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, + space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}} }, + destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, + domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } + }, + metadata: { + labels: {}, + annotations: {} + }, + options: {} + } + end + + let(:expected_codes_and_responses) do + h = Hash.new( + { code: 403 }.freeze + ) + h['admin'] = { + code: 201, + response_object: route_json + } + h['space_developer'] = { + code: 201, + response_object: route_json + } + h['space_supporter'] = { + code: 201, + response_object: route_json + } + h + end + + context 'and the user provides a valid port' do + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + + context 'and a route with the domain and port already exist' do + let!(:duplicate_route) { VCAP::CloudController::Route.make(host: '', space: space, domain: domain, port: 123) } + + it 'fails with a helpful error message' do + post '/v3/routes', params.to_json, admin_headers + expect(last_response).to have_status_code(422) + expect(last_response).to have_error_message("Route already exists with port '123' for domain 'my.domain'.") + end + end + + context 'and the port is already in use for the router group' do + let!(:other_domain) { VCAP::CloudController::SharedDomain.make(router_group_guid: 'some-router-group', name: 'my.domain2') } + let!(:route_with_port) { VCAP::CloudController::Route.make(host: '', space: space, domain: other_domain, port: 123) } + + it 'fails with a helpful error message' do + post '/v3/routes', params.to_json, admin_headers + expect(last_response).to have_status_code(422) + expect(last_response).to have_error_message("Port '123' is not available. Try a different port or use a different domain.") + end + end + end + + context 'and the user does not provide a port' do + let(:params) do + { + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + + context 'and randomly selected port is already in use' do + let(:existing_route) { VCAP::CloudController::Route.make(host: '', space: space, domain: domain, port: 123) } + + let(:params) do + { + port: existing_route.port, + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + it 'fails with a helpful error message' do + post '/v3/routes', params.to_json, admin_headers + expect(last_response).to have_status_code(422) + expect(last_response).to have_error_message("Route already exists with port '123' for domain 'my.domain'.") + end + end + end + end + end + + context 'when creating a route in a suspended org' do + before do + org.update(status: VCAP::CloudController::Organization::SUSPENDED) + end + + let(:domain) { VCAP::CloudController::SharedDomain.make } + + let(:params) do + { + host: 'some-host', + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + let(:route_json) do + { + guid: UUID_REGEX, + protocol: domain.protocols[0], + host: 'some-host', + path: '', + port: nil, + url: "some-host.#{domain.name}", + created_at: iso8601, + updated_at: iso8601, + destinations: [], + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + }, + metadata: { + labels: {}, + annotations: {} + }, + links: { + self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, + space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}} }, + destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, + domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } + }, + options: {} + } + end + + describe 'valid routes' do + let(:api_call) { ->(user_headers) { post '/v3/routes', params.to_json, user_headers } } + + let(:expected_codes_and_responses) do + h = Hash.new({ code: 403, errors: CF_NOT_AUTHORIZED }.freeze) + h['admin'] = { + code: 201, + response_object: route_json + } + %w[space_developer space_supporter].each { |r| h[r] = { code: 403, errors: CF_ORG_SUSPENDED } } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + end + + context 'when creating a route in an internal domain' do + let(:domain) { VCAP::CloudController::SharedDomain.make(internal: true) } + + describe 'when creating a route with a wildcard host' do + let(:params) do + { + host: '*', + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + it 'fails with a helpful message' do + post '/v3/routes', params.to_json, admin_header + expect(last_response).to have_status_code(422) + expect(last_response).to have_error_message('Wildcard hosts are not supported for internal domains.') + end + end + + describe 'when creating a route with a path' do + let(:params) do + { + host: 'host', + path: '/apath', + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + it 'fails with a helpful message' do + post '/v3/routes', params.to_json, admin_header + expect(last_response).to have_status_code(422) + expect(last_response).to have_error_message('Paths are not supported for internal domains.') + end + end + + describe 'when creating a route with a host' do + let(:params) do + { + host: 'some-host', + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + let(:route_json) do + { + guid: UUID_REGEX, + protocol: domain.protocols[0], + host: 'some-host', + path: '', + port: nil, + url: "some-host.#{domain.name}", + created_at: iso8601, + updated_at: iso8601, + destinations: [], + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + }, + links: { + self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, + space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}} }, + destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, + domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } + }, + metadata: { + labels: {}, + annotations: {} + }, + options: {} + } + end + + describe 'valid routes' do + let(:api_call) { ->(user_headers) { post '/v3/routes', params.to_json, user_headers } } + + let(:expected_codes_and_responses) do + h = Hash.new( + { code: 403 }.freeze + ) + h['admin'] = { + code: 201, + response_object: route_json + } + h['space_developer'] = { + code: 201, + response_object: route_json + } + h['space_supporter'] = { + code: 201, + response_object: route_json + } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + end + end + + context 'when the domain has an owning org that is different from the space\'s parent org' do + let(:other_org) { VCAP::CloudController::Organization.make } + let(:inaccessible_domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: other_org) } + + let(:params_with_inaccessible_domain) do + { + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: inaccessible_domain.guid } + } + } + } + end + + it 'returns a 422 with a helpful error message' do + post '/v3/routes', params_with_inaccessible_domain.to_json, admin_header + expect(last_response).to have_status_code(422) + expect(last_response).to have_error_message("Invalid domain. Domain '#{inaccessible_domain.name}' is not available in organization '#{org.name}'.") + end + end + + context 'when the host-less route has already been created for this domain' do + let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } + let!(:existing_route) { VCAP::CloudController::Route.make(host: '', space: space, domain: domain) } + + let(:params_for_duplicate_route) do + { + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + it 'returns a 422 with a helpful error message' do + post '/v3/routes', params_for_duplicate_route.to_json, admin_header + expect(last_response).to have_status_code(422) + expect(last_response).to have_error_message("Route already exists for domain '#{domain.name}'.") + end + end + + context 'when there is already a route' do + context 'with the host/domain/path combination' do + let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } + let!(:existing_route) { VCAP::CloudController::Route.make(host: 'my-host', path: '/existing', space: space, domain: domain) } + + let(:params_for_duplicate_route) do + { + host: existing_route.host, + path: existing_route.path, + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + it 'returns a 422 with a helpful error message' do + post '/v3/routes', params_for_duplicate_route.to_json, admin_header + expect(last_response).to have_status_code(422) + expect(last_response).to have_error_message("Route already exists with host '#{existing_route.host}' and path '#{existing_route.path}' for domain '#{domain.name}'.") + end + end + + context 'with the host/domain combination' do + let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } + let!(:existing_route) { VCAP::CloudController::Route.make(host: 'my-host', space: space, domain: domain) } + + let(:params_for_duplicate_route) do + { + host: existing_route.host, + path: existing_route.path, + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + it 'returns a 422 with a helpful error message' do + post '/v3/routes', params_for_duplicate_route.to_json, admin_header + expect(last_response).to have_status_code(422) + expect(last_response).to have_error_message("Route already exists with host '#{existing_route.host}' for domain '#{domain.name}'.") + end + end + end + + context 'when there is already a domain matching the host/domain combination' do + let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } + let!(:existing_domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization, name: "#{params[:host]}.#{domain.name}") } + + let(:params) do + { + host: 'some-host', + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + it 'returns a 422 with a helpful error message' do + post '/v3/routes', params.to_json, admin_header + expect(last_response).to have_status_code(422) + expect(last_response).to have_error_message("Route conflicts with domain '#{existing_domain.name}'.") + end + end + + context 'when using a reserved system hostname with the system domain' do + let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } + + let(:params) do + { + host: 'host', + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + before do + VCAP::CloudController::Config.config.set(:system_domain, domain.name) + VCAP::CloudController::Config.config.set(:system_hostnames, [params[:host]]) + end + + it 'returns a 422 with a helpful error message' do + post '/v3/routes', params.to_json, admin_header + expect(last_response).to have_status_code(422) + expect(last_response).to have_error_message('Route conflicts with a reserved system route.') + end + end + + context 'when using a non-reserved hostname with the system domain' do + let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } + let(:api_call) { ->(user_headers) { post '/v3/routes', params.to_json, user_headers } } + + let(:params) do + { + host: 'host', + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + let(:route_json) do + { + guid: UUID_REGEX, + protocol: domain.protocols[0], + host: params[:host], + path: '', + port: nil, + url: "#{params[:host]}.#{domain.name}", + created_at: iso8601, + updated_at: iso8601, + destinations: [], + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + }, + metadata: { + labels: {}, + annotations: {} + }, + links: { + self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, + space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}} }, + destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, + domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } + }, + options: {} + } + end + + let(:expected_codes_and_responses) do + h = Hash.new( + { code: 403 }.freeze + ) + h['admin'] = { + code: 201, + response_object: route_json + } + h['space_developer'] = { + code: 201, + response_object: route_json + } + h['space_supporter'] = { + code: 201, + response_object: route_json + } + h + end + + before do + VCAP::CloudController::Config.config.set(:system_domain, domain.name) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + + describe 'quotas' do + context 'when the space quota for routes is maxed out' do + let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } + let!(:space_quota_definition) { VCAP::CloudController::SpaceQuotaDefinition.make(total_routes: 0, organization: org) } + let!(:space_with_quota) { VCAP::CloudController::Space.make(space_quota_definition: space_quota_definition, organization: org) } + + let(:params_for_space_with_quota) do + { + relationships: { + space: { + data: { guid: space_with_quota.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + it 'returns a 422 with a helpful error message' do + post '/v3/routes', params_for_space_with_quota.to_json, admin_header + expect(last_response).to have_status_code(422) + expect(last_response).to have_error_message("Routes quota exceeded for space '#{space_with_quota.name}'.") + end + end + + context 'when the org quota for routes is maxed out' do + let!(:org_quota_definition) { VCAP::CloudController::QuotaDefinition.make(total_routes: 0, total_reserved_route_ports: 0) } + let!(:org_with_quota) { VCAP::CloudController::Organization.make(quota_definition: org_quota_definition) } + let!(:space_in_org_with_quota) do + VCAP::CloudController::Space.make(organization: org_with_quota) + end + let(:domain_in_org_with_quota) { VCAP::CloudController::Domain.make(owning_organization: org_with_quota) } + + let(:params_for_org_with_quota) do + { + relationships: { + space: { + data: { guid: space_in_org_with_quota.guid } + }, + domain: { + data: { guid: domain_in_org_with_quota.guid } + } + } + } + end + + it 'returns a 422 with a helpful error message' do + post '/v3/routes', params_for_org_with_quota.to_json, admin_header + expect(last_response).to have_status_code(422) + expect(last_response).to have_error_message("Routes quota exceeded for organization '#{org_with_quota.name}'.") + end + end + end + + context 'when the feature flag is disabled' do + let(:headers) { set_user_with_header_as_role(user: user, role: 'space_developer', org: org, space: space) } + let!(:feature_flag) { VCAP::CloudController::FeatureFlag.make(name: 'route_creation', enabled: false, error_message: 'my name is bob') } + let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } + let(:params) do + { + host: 'some-host', + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + context 'when the user is not an admin' do + it 'returns a 403' do + post '/v3/routes', params.to_json, headers + + expect(last_response).to have_status_code(403) + expect(parsed_response['errors'][0]['detail']).to eq('Feature Disabled: my name is bob') + end + end + + context 'when the user is an admin' do + let(:headers) { set_user_with_header_as_role(role: 'admin') } + + it 'allows creation' do + post '/v3/routes', params.to_json, headers + + expect(last_response).to have_status_code(201) + end + end + end + + context 'when the user is not logged in' do + it 'returns 401 for Unauthenticated requests' do + post '/v3/routes', {}.to_json, base_json_headers + expect(last_response).to have_status_code(401) + end + end + + context 'when the user does not have the required scopes' do + let(:user_header) { headers_for(user, scopes: ['cloud_controller.read']) } + + it 'returns a 403' do + post '/v3/routes', {}.to_json, user_header + expect(last_response).to have_status_code(403) + end + end + + context 'when the space does not exist' do + let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } + + let(:params_with_invalid_space) do + { + relationships: { + space: { + data: { guid: 'invalid-space' } + }, + domain: { + data: { guid: domain.guid } + } + } + } + end + + it 'returns a 422 with a helpful error message' do + post '/v3/routes', params_with_invalid_space.to_json, admin_header + expect(last_response).to have_status_code(422) + expect(last_response).to have_error_message('Invalid space. Ensure that the space exists and you have access to it.') + end + end + + context 'when the domain does not exist' do + let(:params_with_invalid_domain) do + { + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: 'invalid-domain' } + } + } + } + end + + it 'returns a 422 with a helpful error message' do + post '/v3/routes', params_with_invalid_domain.to_json, admin_header + expect(last_response).to have_status_code(422) + expect(last_response).to have_error_message('Invalid domain. Ensure that the domain exists and you have access to it.') + end + end + + context 'when communicating with the routing API' do + let(:routing_api_client) { instance_double(VCAP::CloudController::RoutingApi::Client) } + let(:router_group) { VCAP::CloudController::RoutingApi::RouterGroup.new({ 'type' => 'tcp', 'guid' => 'some-guid' }) } + let(:headers) { set_user_with_header_as_role(role: 'admin') } + let(:domain_tcp) { VCAP::CloudController::SharedDomain.make(router_group_guid: router_group.guid, name: 'my.domain') } + let(:params) do + { + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain_tcp.guid } + } + } + } + end + + before do + allow_any_instance_of(CloudController::DependencyLocator).to receive(:routing_api_client).and_return(routing_api_client) + end + + context 'when UAA is unavailable' do + before do + allow(routing_api_client).to receive(:router_group).and_raise VCAP::CloudController::RoutingApi::UaaUnavailable + end + + it 'returns a 503 with a helpful error message' do + post '/v3/routes', params.to_json, headers + + expect(last_response).to have_status_code(503) + expect(parsed_response['errors'][0]['detail']).to eq 'Communicating with the Routing API failed because UAA is currently unavailable. Please try again later.' + end + end + + context 'when the routing API is unavailable' do + before do + allow(routing_api_client).to receive(:enabled?).and_return true + allow(routing_api_client).to receive(:router_group).and_raise VCAP::CloudController::RoutingApi::RoutingApiUnavailable + end + + it 'returns a 503 with a helpful error message' do + post '/v3/routes', params.to_json, headers + + expect(last_response).to have_status_code(503) + expect(parsed_response['errors'][0]['detail']).to eq 'The Routing API is currently unavailable. Please try again later.' + end + end + + context 'when the routing API is disabled' do + before do + allow(routing_api_client).to receive(:enabled?).and_return false + allow(routing_api_client).to receive(:router_group).and_raise VCAP::CloudController::RoutingApi::RoutingApiDisabled + end + + it 'returns a 503 with a helpful error message' do + post '/v3/routes', params.to_json, headers + + expect(last_response).to have_status_code(503) + expect(parsed_response['errors'][0]['detail']).to eq 'The Routing API is disabled.' + end + end + + context 'when the router group is unavailable' do + let(:domain_tcp) { VCAP::CloudController::SharedDomain.make(router_group_guid: 'not a guid', name: 'my.domain') } + + before do + allow(routing_api_client).to receive_messages(enabled?: true, router_group: nil) + end + + it 'returns a 503 with a helpful error message' do + post '/v3/routes', params.to_json, headers + + expect(last_response.status).to eq(422) + expect(parsed_response['errors'][0]['detail']).to eq 'Route could not be created because the specified domain does not have a valid router group.' + end + end + end + end + + describe 'PATCH /v3/routes/:guid' do + let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } + let(:route) { VCAP::CloudController::Route.make(space: space, domain: domain, host: '') } + let(:api_call) { ->(user_headers) { patch "/v3/routes/#{route.guid}", params.to_json, user_headers } } + let(:params) do + { + metadata: { + labels: { + potato: 'fingerling', + style: 'roasted' + }, + annotations: { + potato: 'russet', + style: 'fried' + } + } + } + end + + let(:route_json) do + { + guid: UUID_REGEX, + protocol: domain.protocols[0], + host: '', + path: '', + port: nil, + url: domain.name, + created_at: iso8601, + updated_at: iso8601, + destinations: [], + relationships: { + space: { + data: { guid: space.guid } + }, + domain: { + data: { guid: domain.guid } + } + }, + links: { + self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, + space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{space.guid}} }, + destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, + domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } + }, + metadata: { + labels: { + potato: 'fingerling', + style: 'roasted' + }, + annotations: { + potato: 'russet', + style: 'fried' + } + }, + options: {} + } + end + + context 'when the user logged in' do + let(:expected_codes_and_responses) do + h = Hash.new({ code: 403, errors: CF_NOT_AUTHORIZED }.freeze) + h['admin'] = { code: 200, response_object: route_json } + h['no_role'] = { code: 404 } + h['org_billing_manager'] = { code: 404 } + h['space_developer'] = { code: 200, response_object: route_json } + h['space_supporter'] = { code: 200, response_object: route_json } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + + context 'when organization is suspended' do + let(:expected_codes_and_responses) do + h = super() + %w[space_developer space_supporter].each { |r| h[r] = { code: 403, errors: CF_ORG_SUSPENDED } } + h + end + + before do + org.update(status: VCAP::CloudController::Organization::SUSPENDED) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + end + + context 'when the user is not a member in the routes org' do + let(:other_space) { VCAP::CloudController::Space.make } + let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: other_space.organization) } + let(:route) { VCAP::CloudController::Route.make(space: other_space, domain: domain, host: '') } + + let(:route_json) do + { + guid: UUID_REGEX, + protocol: domain.protocols[0], + host: '', + path: '', + port: nil, + url: domain.name, + created_at: iso8601, + updated_at: iso8601, + destinations: [], + relationships: { + space: { + data: { guid: other_space.guid } + }, + domain: { + data: { guid: domain.guid } + } + }, + links: { + self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}} }, + space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{other_space.guid}} }, + destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{UUID_REGEX}/destinations} }, + domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{domain.guid}} } + }, + metadata: { + labels: { + potato: 'fingerling', + style: 'roasted' + }, + annotations: { + potato: 'russet', + style: 'fried' + } + }, + options: {} + } + end + let(:expected_codes_and_responses) do + h = Hash.new({ code: 404 }.freeze) + h['admin'] = { + code: 200, + response_object: route_json + } + h['admin_read_only'] = { + code: 403 + } + h['global_auditor'] = { + code: 403 + } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + + context 'when route does not exist' do + it 'returns a 404 with a helpful error message' do + patch "/v3/routes/#{user.guid}", params.to_json, admin_header + + expect(last_response).to have_status_code(404) + expect(last_response).to have_error_message('Route not found') + end + end + + context 'when request input message is invalid' do + let(:params_with_invalid_input) do + { + disallowed_key: 'val' + } + end + + it 'returns a 422' do + patch "/v3/routes/#{route.guid}", params_with_invalid_input.to_json, admin_header + + expect(last_response).to have_status_code(422) + end + end + + context 'when metadata is given with invalid format' do + let(:params_with_invalid_metadata_format) do + { + metadata: { + labels: { + "": 'mashed', + '/potato': '.value.' + } + } + } + end + + it 'returns a 422' do + patch "/v3/routes/#{route.guid}", params_with_invalid_metadata_format.to_json, admin_header + + expect(last_response).to have_status_code(422) + expect(parsed_response['errors'][0]['detail']).to match(/Metadata [\w\s]+ error/) + end + end + + context 'when the user is not logged in' do + it 'returns 401 for Unauthenticated requests' do + patch "/v3/routes/#{route.guid}", nil, base_json_headers + expect(last_response).to have_status_code(401) + end + end + end + + describe 'DELETE /v3/routes/:guid' do + let(:domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: space.organization) } + let(:route) { VCAP::CloudController::Route.make(space:, domain:) } + let(:api_call) { ->(user_headers) { delete "/v3/routes/#{route.guid}", nil, user_headers } } + let(:db_check) do + lambda do + expect(last_response.headers['Location']).to match(%r{http.+/v3/jobs/[a-fA-F0-9-]+}) + + execute_all_jobs(expected_successes: 1, expected_failures: 0) + get "/v3/routes/#{route.guid}", {}, admin_headers + expect(last_response).to have_status_code(404) + end + end + + context 'deleting metadata' do + it_behaves_like 'resource with metadata' do + let(:resource) { route } + let(:api_call) do + -> { delete "/v3/routes/#{route.guid}", nil, admin_header } + end + end + end + + context 'when the user is a member in the routes org' do + let(:expected_codes_and_responses) do + h = Hash.new({ code: 403, errors: CF_NOT_AUTHORIZED }.freeze) + h['org_billing_manager'] = { code: 404 } + h['no_role'] = { code: 404 } + h['admin'] = { code: 202 } + h['space_developer'] = { code: 202 } + h['space_supporter'] = { code: 202 } + h + end + + it_behaves_like 'permissions for delete endpoint', ALL_PERMISSIONS do + let(:expected_event_hash) do + { + type: 'audit.route.delete-request', + actee: route.guid, + actee_type: 'route', + actee_name: route.host, + metadata: { request: { recursive: true } }.to_json, + space_guid: space.guid, + organization_guid: org.guid + } + end + end + + context 'when organization is suspended' do + let(:expected_codes_and_responses) do + h = super() + %w[space_developer space_supporter].each { |r| h[r] = { code: 403, errors: CF_ORG_SUSPENDED } } + h + end + + before do + org.update(status: VCAP::CloudController::Organization::SUSPENDED) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + end + + describe 'when the user is not logged in' do + it 'returns 401 for Unauthenticated requests' do + delete "/v3/routes/#{route.guid}", nil, base_json_headers + expect(last_response).to have_status_code(401) + end + end + end + + describe 'GET /v3/routes/:guid/relationships/shared_spaces' do + let(:api_call) { ->(user_headers) { get "/v3/routes/#{guid}/relationships/shared_spaces", nil, user_headers } } + let(:target_space_1) { VCAP::CloudController::Space.make(organization: org) } + let(:route) do + route = VCAP::CloudController::Route.make(space:) + route.add_shared_space(target_space_1) + route + end + let(:guid) { route.guid } + let(:space_dev_headers) do + org.add_user(user) + space.add_developer(user) + headers_for(user) + end + let!(:feature_flag) do + VCAP::CloudController::FeatureFlag.make(name: 'route_sharing', enabled: true, error_message: nil) + end + + before do + org.add_user(user) + target_space_1.add_developer(user) + end + + describe 'permissions' do + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS do + let(:expected_codes_and_responses) do + h = Hash.new({ code: 200, response_object: { + data: [ + { + guid: target_space_1.guid + } + ], + links: { + self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{route.guid}/relationships/shared_spaces} } + } + } }.freeze) + + h['org_billing_manager'] = { code: 404 } + h['no_role'] = { code: 404 } + h + end + end + end + + describe 'when route_sharing flag is disabled' do + before do + feature_flag.enabled = false + feature_flag.save + end + + it 'makes users unable to unshare routes' do + api_call.call(space_dev_headers) + + expect(last_response).to have_status_code(403) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => 'Feature Disabled: route_sharing', + 'title' => 'CF-FeatureDisabled', + 'code' => 330_002 + } + ) + ) + end + end + + it 'responds with 404 when the route does not exist' do + get '/v3/routes/some-fake-guid/relationships/shared_spaces', nil, space_dev_headers + + expect(last_response).to have_status_code(404) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => 'Route not found', + 'title' => 'CF-ResourceNotFound' + } + ) + ) + end + end + + describe 'POST /v3/routes/:guid/relationships/shared_spaces' do + let(:api_call) { ->(user_headers) { post "/v3/routes/#{guid}/relationships/shared_spaces", request_body.to_json, user_headers } } + let(:target_space_1) { VCAP::CloudController::Space.make(organization: org) } + let(:target_space_2) { VCAP::CloudController::Space.make(organization: org) } + let(:request_body) do + { + 'data' => [ + { 'guid' => target_space_1.guid }, + { 'guid' => target_space_2.guid } + ] + } + end + let(:route) { VCAP::CloudController::Route.make(space:) } + let(:guid) { route.guid } + let(:space_dev_headers) do + org.add_user(user) + space.add_developer(user) + headers_for(user) + end + let!(:feature_flag) do + VCAP::CloudController::FeatureFlag.make(name: 'route_sharing', enabled: true, error_message: nil) + end + + before do + org.add_user(user) + target_space_1.add_developer(user) + target_space_2.add_developer(user) + end + + describe 'permissions' do + let(:expected_codes_and_responses) do + h = Hash.new({ code: 403, errors: CF_NOT_AUTHORIZED }.freeze) + + h['org_billing_manager'] = { code: 404 } + h['no_role'] = { code: 404 } + + h['admin'] = { code: 200 } + h['space_developer'] = { code: 200 } + h['space_supporter'] = { code: 200 } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + + context 'when organization is suspended' do + let(:expected_codes_and_responses) do + h = super() + %w[space_developer space_supporter].each { |r| h[r] = { code: 403, errors: CF_ORG_SUSPENDED } } + h + end + + before do + org.update(status: VCAP::CloudController::Organization::SUSPENDED) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + + context 'when target organization is suspended' do + let(:target_space_1) do + space = VCAP::CloudController::Space.make + space.organization.add_user(user) + space.organization.update(status: VCAP::CloudController::Organization::SUSPENDED) + space + end + + let(:expected_codes_and_responses) do + h = super() + %w[space_developer space_supporter].each { |r| h[r] = { code: 422 } } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + end + + it 'shares the route to the target space and logs audit event' do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(200) + + event = VCAP::CloudController::Event.last + expect(event.values).to include({ + type: 'audit.route.share', + actor: user.guid, + actee_type: 'route', + actee_name: route.host, + space_guid: space.guid, + organization_guid: space.organization.guid + }) + expect(event.metadata['target_space_guids']).to include(target_space_1.guid, target_space_2.guid) + + route.reload + expect(route.shared_spaces).to include(target_space_1, target_space_2) + end + + it 'reports that the route is now shared' do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(200) + route.reload + expect(route.shared_spaces).to include(target_space_1, target_space_2) + expect(route).to be_shared + end + + it 'reports that the route is not shared when it has not been shared' do + route.reload + expect(route.shared_spaces).to be_empty + expect(route).not_to be_shared + end + + describe 'when route_sharing flag is disabled' do + before do + feature_flag.enabled = false + feature_flag.save + end + + it 'makes users unable to share routes' do + api_call.call(space_dev_headers) + + expect(last_response).to have_status_code(403) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => 'Feature Disabled: route_sharing', + 'title' => 'CF-FeatureDisabled', + 'code' => 330_002 + } + ) + ) + end + end + + it 'responds with 404 when the route does not exist' do + post '/v3/routes/some-fake-guid/relationships/shared_spaces', request_body.to_json, space_dev_headers + + expect(last_response).to have_status_code(404) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => 'Route not found', + 'title' => 'CF-ResourceNotFound' + } + ) + ) + end + + describe 'when the request body is invalid' do + context 'when it is not a valid relationship' do + let(:request_body) do + { + 'data' => { 'guid' => target_space_1.guid } + } + end + + it 'responds with 422' do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(422) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => 'Data must be an array', + 'title' => 'CF-UnprocessableEntity' + } + ) + ) + end + end + + context 'when there are additional keys' do + let(:request_body) do + { + 'data' => [ + { 'guid' => target_space_1.guid } + ], + 'fake-key' => 'foo' + } + end + + it 'responds with 422' do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(422) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => "Unknown field(s): 'fake-key'", + 'title' => 'CF-UnprocessableEntity' + } + ) + ) + end + end + end + + describe 'target space to share to' do + context 'does not exist' do + let(:target_space_guid) { 'fake-target' } + let(:request_body) do + { + 'data' => [ + { 'guid' => target_space_guid } + ] + } + end + + it 'responds with 422' do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(422) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => "Unable to share route #{route.uri} with spaces ['#{target_space_guid}']. " \ + 'Ensure the spaces exist and that you have access to them.', + 'title' => 'CF-UnprocessableEntity' + } + ) + ) + end + end + + context 'user does not have access to one of the target spaces' do + let(:no_access_target_space) { VCAP::CloudController::Space.make(organization: org) } + let(:request_body) do + { + 'data' => [ + { 'guid' => no_access_target_space.guid }, + { 'guid' => target_space_1.guid } + ] + } + end + + it 'responds with 422 and does not share the route' do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(422) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => "Unable to share route #{route.uri} with spaces ['#{no_access_target_space.guid}']. " \ + 'Ensure the spaces exist and that you have access to them.', + 'title' => 'CF-UnprocessableEntity' + } + ) + ) + + route.reload + expect(route).not_to be_shared + end + end + + context 'already owns the route' do + let(:request_body) do + { + 'data' => [ + { 'guid' => space.guid }, + { 'guid' => target_space_1.guid } + ] + } + end + + it 'responds with 422 and does not share the route' do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(422) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => "Unable to share route '#{route.uri}' with space '#{space.guid}'. " \ + 'Routes cannot be shared into the space where they were created.', + 'title' => 'CF-UnprocessableEntity' + } + ) + ) + + route.reload + expect(route).not_to be_shared + end + end + end + + describe 'errors while sharing' do + # isolation segments? + end + end + + describe 'DELETE /v3/routes/:guid/relationships/shared_spaces/:space_guid' do + let(:api_call) { ->(user_headers) { delete "/v3/routes/#{guid}/relationships/shared_spaces/#{unshared_space_guid}", request_body.to_json, user_headers } } + let(:target_space_1) { VCAP::CloudController::Space.make(organization: org) } + let(:target_space_2) { VCAP::CloudController::Space.make(organization: org) } + let(:target_space_3) { VCAP::CloudController::Space.make(organization: org) } + let(:target_space_not_shared_with_route) { VCAP::CloudController::Space.make(organization: org) } + let(:space_to_unshare) { target_space_2 } + let(:unshared_space_guid) { space_to_unshare.guid } + let(:request_body) { {} } + let(:route) do + route = VCAP::CloudController::Route.make(space:) + route.add_shared_space(target_space_1) + route.add_shared_space(target_space_2) + route.add_shared_space(target_space_3) + route + end + let(:guid) { route.guid } + let(:space_dev_headers) do + org.add_user(user) + space.add_developer(user) + headers_for(user) + end + let!(:feature_flag) do + VCAP::CloudController::FeatureFlag.make(name: 'route_sharing', enabled: true, error_message: nil) + end + + before do + org.add_user(user) + target_space_1.add_developer(user) + target_space_2.add_developer(user) + target_space_not_shared_with_route.add_developer(user) + end + + describe 'permissions' do + let(:expected_codes_and_responses) do + h = Hash.new({ code: 403, errors: CF_NOT_AUTHORIZED }.freeze) + + h['org_billing_manager'] = { code: 404 } + h['no_role'] = { code: 404 } + + h['admin'] = { code: 204 } + h['space_developer'] = { code: 204 } + h['space_supporter'] = { code: 204 } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + + context 'when organization is suspended' do + let(:expected_codes_and_responses) do + h = super() + %w[space_developer space_supporter].each { |r| h[r] = { code: 403, errors: CF_ORG_SUSPENDED } } + h + end + + before do + org.update(status: VCAP::CloudController::Organization::SUSPENDED) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + + context 'when target organization is suspended' do + let(:space_to_unshare) do + space = VCAP::CloudController::Space.make + space.organization.add_user(user) + space.add_developer(user) + space.organization.update(status: VCAP::CloudController::Organization::SUSPENDED) + space + end + + let(:expected_codes_and_responses) do + h = super() + %w[space_developer space_supporter].each do |r| + h[r] = { + code: 422, + errors: [{ + detail: "Unable to unshare route '#{route.uri}' from space '#{space_to_unshare.guid}'. The target organization is suspended.", + title: 'CF-UnprocessableEntity', + code: 10_008 + }] + } + end + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + end + + it 'unshares the specified route from the target space and logs audit event' do + expect(route.shared_spaces).to include(target_space_1, space_to_unshare, target_space_3) + + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(204) + + event = VCAP::CloudController::Event.last + expect(event.values).to include({ + type: 'audit.route.unshare', + actor: user.guid, + actee_type: 'route', + actee_name: route.host, + space_guid: space.guid, + organization_guid: space.organization.guid + }) + expect(event.metadata['target_space_guid']).to eq(unshared_space_guid) + + route.reload + expect(route.shared_spaces).to include(target_space_1, target_space_3) + end + + describe 'when route_sharing flag is disabled' do + before do + feature_flag.enabled = false + feature_flag.save + end + + it 'makes users unable to unshare routes' do + api_call.call(space_dev_headers) + + expect(last_response).to have_status_code(403) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => 'Feature Disabled: route_sharing', + 'title' => 'CF-FeatureDisabled', + 'code' => 330_002 + } + ) + ) + end + end + + it 'responds with 204 when the route is not shared with the specified space' do + delete "/v3/routes/#{route.guid}/relationships/shared_spaces/#{target_space_not_shared_with_route.guid}", request_body.to_json, space_dev_headers + + expect(last_response.status).to eq(204) + end + + it "responds with 404 when the route doesn't exist" do + delete "/v3/routes/some-fake-guid/relationships/shared_spaces/#{target_space_1.guid}", request_body.to_json, space_dev_headers + + expect(last_response).to have_status_code(404) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => 'Route not found', + 'title' => 'CF-ResourceNotFound' + } + ) + ) + end + + context 'attempting to unshare from space that owns us' do + let(:space_to_unshare) { space } + + it 'responds with 422 and does not unshare the roue' do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(422) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => "Unable to unshare route '#{route.uri}' from space " \ + "'#{space.guid}'. Routes cannot be removed from the space that owns them.", + 'title' => 'CF-UnprocessableEntity' + } + ) + ) + + route.reload + expect(route.shared_spaces).to contain_exactly(target_space_1, target_space_2, target_space_3) + end + end + + describe 'target space to unshare with' do + context 'does not exist' do + let(:unshared_space_guid) { 'fake-target' } + + it 'responds with 422' do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(422) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => "Unable to unshare route '#{route.uri}' from space '#{unshared_space_guid}'. " \ + 'Ensure the space exists and that you have access to it.', + 'title' => 'CF-UnprocessableEntity' + } + ) + ) + end + end + + context 'user does not have read access to the target space' do + let(:unshared_space_guid) { VCAP::CloudController::Space.make(organization: org).guid } + + it 'responds with 422 and does not share the route' do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(422) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => "Unable to unshare route '#{route.uri}' from space '#{unshared_space_guid}'. " \ + 'Ensure the space exists and that you have access to it.', + 'title' => 'CF-UnprocessableEntity' + } + ) + ) + end + end + + context 'user does not have write access to the target space' do + let(:no_write_access_target_space) { VCAP::CloudController::Space.make(organization: org) } + let(:unshared_space_guid) { no_write_access_target_space.guid } + + before do + no_write_access_target_space.add_auditor(user) + end + + it 'responds with 422 and does not share the route' do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(422) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => "Unable to unshare route '#{route.uri}' from space '#{no_write_access_target_space.guid}'. " \ + "You don't have write permission for the target space.", + 'title' => 'CF-UnprocessableEntity' + } + ) + ) + end + end + end + end + + describe 'PATCH /v3/routes/:guid/relationships/space' do + let(:shared_domain) { VCAP::CloudController::SharedDomain.make } + let(:route) { VCAP::CloudController::Route.make(space: space, domain: shared_domain) } + let(:api_call) { ->(user_headers) { patch "/v3/routes/#{route.guid}/relationships/space", request_body.to_json, user_headers } } + let(:target_space) { VCAP::CloudController::Space.make(organization: org) } + let(:request_body) do + { + data: { 'guid' => target_space.guid } + } + end + let(:space_dev_headers) do + org.add_user(user) + space.add_developer(user) + headers_for(user) + end + let!(:feature_flag) do + VCAP::CloudController::FeatureFlag.make(name: 'route_sharing', enabled: true, error_message: nil) + end + + before do + org.add_user(user) + target_space.add_developer(user) + end + + context 'when the user logged in' do + let(:expected_codes_and_responses) do + h = Hash.new({ code: 403, errors: CF_NOT_AUTHORIZED }.freeze) + h['admin'] = { code: 200 } + h['no_role'] = { code: 404 } + h['org_billing_manager'] = { code: 404 } + h['space_developer'] = { code: 200 } + h + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + + context 'when organization is suspended' do + let(:expected_codes_and_responses) do + h = super() + %w[space_developer].each { |r| h[r] = { code: 403, errors: CF_ORG_SUSPENDED } } + h + end + + before do + org.update(status: VCAP::CloudController::Organization::SUSPENDED) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + + context 'when target organization is suspended' do + let(:suspended_space) { VCAP::CloudController::Space.make } + let(:request_body) do + { + data: { 'guid' => suspended_space.guid } + } + end + + let(:expected_codes_and_responses) do + h = super() + %w[space_developer].each do |r| + h[r] = { + code: 422, + errors: [{ + detail: "Unable to transfer owner of route '#{route.uri}' to space '#{suspended_space.guid}'. The target organization is suspended.", + title: 'CF-UnprocessableEntity', + code: 10_008 + }] + } + end + h + end + + before do + suspended_space.organization.add_user(user) + suspended_space.add_developer(user) + suspended_space.organization.update(status: VCAP::CloudController::Organization::SUSPENDED) + end + + it_behaves_like 'permissions for single object endpoint', ALL_PERMISSIONS + end + end + + it 'changes the route owner to the given space and logs an event', isolation: :truncation do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(200) + + event = VCAP::CloudController::Event.last + expect(event.values).to include({ + type: 'audit.route.transfer-owner', + actor: user.guid, + actee_type: 'route', + actee_name: route.host, + space_guid: space.guid, + organization_guid: space.organization.guid + }) + expect(event.metadata['target_space_guid']).to eq(target_space.guid) + + route.reload + expect(route.space).to eq target_space + end + + describe 'when using a private domain' do + let(:private_domain) { VCAP::CloudController::PrivateDomain.make(owning_organization: org) } + let(:route) { VCAP::CloudController::Route.make(space: space, domain: private_domain) } + let(:second_org) { VCAP::CloudController::Organization.make } + let(:another_space) { VCAP::CloudController::Space.make(organization: second_org) } + let(:request_body) do + { + data: { 'guid' => another_space.guid } + } + end + let(:space_dev_headers) do + org.add_user(user) + space.add_developer(user) + second_org.add_user(user) + another_space.add_developer(user) + headers_for(user) + end + + it 'responds with 422' do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(422) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => "Unable to transfer owner of route '#{route.uri}' to space '#{another_space.guid}'. " \ + "Target space does not have access to route's domain", + 'title' => 'CF-UnprocessableEntity' + } + ) + ) + end + end + + describe 'target space to transfer to' do + context 'does not exist' do + let(:target_space_guid) { 'fake-target' } + let(:request_body) do + { + data: { 'guid' => target_space_guid } + } + end + + it 'responds with 422' do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(422) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => "Unable to transfer owner of route '#{route.uri}' to space '#{target_space_guid}'. " \ + 'Ensure the space exists and that you have access to it.', + 'title' => 'CF-UnprocessableEntity' + } + ) + ) + end + end + + context 'user does not have read access to the target space' do + let(:no_access_target_space) { VCAP::CloudController::Space.make(organization: org) } + let(:request_body) do + { + data: { 'guid' => no_access_target_space.guid } + } + end + + it 'responds with 422 and does not share the route' do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(422) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => "Unable to transfer owner of route '#{route.uri}' to space '#{no_access_target_space.guid}'. " \ + 'Ensure the space exists and that you have access to it.', + 'title' => 'CF-UnprocessableEntity' + } + ) + ) + end + end + + context 'user does not have write access to the target space' do + let(:no_write_access_target_space) { VCAP::CloudController::Space.make(organization: org) } + let(:request_body) do + { + data: { 'guid' => no_write_access_target_space.guid } + } + end + + before do + no_write_access_target_space.add_auditor(user) + end + + it 'responds with 422 and does not share the route' do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(422) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => "Unable to transfer owner of route '#{route.uri}' to space '#{no_write_access_target_space.guid}'. " \ + "You don't have write permission for the target space.", + 'title' => 'CF-UnprocessableEntity' + } + ) + ) + end + end + end + + it 'responds with 404 when the route does not exist' do + patch '/v3/routes/some-fake-guid/relationships/space', request_body.to_json, space_dev_headers + + expect(last_response).to have_status_code(404) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => 'Route not found', + 'title' => 'CF-ResourceNotFound' + } + ) + ) + end + + describe 'when the request body is invalid' do + context 'when there are additional keys' do + let(:request_body) do + { + data: { 'guid' => target_space.guid }, + 'fake-key' => 'foo' + } + end + + it 'responds with 422' do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(422) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => "Unknown field(s): 'fake-key'", + 'title' => 'CF-UnprocessableEntity' + } + ) + ) + end + end + + context 'when data is not a hash' do + let(:request_body) do + { + data: [{ 'guid' => target_space.guid }] + } + end + + it 'responds with 422' do + api_call.call(space_dev_headers) + + expect(last_response.status).to eq(422) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => 'Data must be an object', + 'title' => 'CF-UnprocessableEntity' + } + ) + ) + end + end + end + + describe 'when route_sharing flag is disabled' do + before do + feature_flag.enabled = false + feature_flag.save + end + + it 'makes users unable to transfer-owner' do + api_call.call(space_dev_headers) + + expect(last_response).to have_status_code(403) + expect(parsed_response['errors']).to include( + include( + { + 'detail' => 'Feature Disabled: route_sharing', + 'title' => 'CF-FeatureDisabled', + 'code' => 330_002 + } + ) + ) + end + end + end + + describe 'GET /v3/apps/:app_guid/routes' do + let(:app_model) { VCAP::CloudController::AppModel.make(space:) } + let(:route1) { VCAP::CloudController::Route.make(space:) } + let(:route2) { VCAP::CloudController::Route.make(space:) } + let!(:route3) { VCAP::CloudController::Route.make(space:) } + let!(:route_mapping1) { VCAP::CloudController::RouteMappingModel.make(app: app_model, route: route1, process_type: 'web') } + let!(:route_mapping2) { VCAP::CloudController::RouteMappingModel.make(app: app_model, route: route2, process_type: 'admin') } + let(:api_call) { ->(user_headers) { get "/v3/apps/#{app_model.guid}/routes", nil, user_headers } } + + let(:route1_json) do + { + guid: route1.guid, + protocol: route1.domain.protocols[0], + host: route1.host, + path: route1.path, + port: nil, + url: "#{route1.host}.#{route1.domain.name}#{route1.path}", + created_at: iso8601, + updated_at: iso8601, + destinations: contain_exactly({ + guid: route_mapping1.guid, + app: { + guid: app_model.guid, + process: { + type: route_mapping1.process_type + } + }, + weight: route_mapping1.weight, + port: route_mapping1.presented_port, + protocol: 'http1', + created_at: iso8601, + updated_at: iso8601 + }), + relationships: { + space: { + data: { guid: route1.space.guid } + }, + domain: { + data: { guid: route1.domain.guid } + } + }, + metadata: { + labels: {}, + annotations: {} + }, + links: { + self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{route1.guid}} }, + space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{route1.space.guid}} }, + destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{route1.guid}/destinations} }, + domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{route1.domain.guid}} } + }, + options: {} + } + end + + let(:route2_json) do + { + guid: route2.guid, + protocol: route2.domain.protocols[0], + host: route2.host, + path: route2.path, + port: nil, + url: "#{route2.host}.#{route2.domain.name}#{route2.path}", + created_at: iso8601, + updated_at: iso8601, + destinations: contain_exactly({ + guid: route_mapping2.guid, + app: { + guid: app_model.guid, + process: { + type: route_mapping2.process_type + } + }, + weight: route_mapping2.weight, + port: route_mapping2.presented_port, + protocol: 'http1', + created_at: iso8601, + updated_at: iso8601 + }), + relationships: { + space: { + data: { guid: route2.space.guid } + }, + domain: { + data: { guid: route2.domain.guid } + } + }, + metadata: { + labels: {}, + annotations: {} + }, + links: { + self: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{route2.guid}} }, + space: { href: %r{#{Regexp.escape(link_prefix)}/v3/spaces/#{route2.space.guid}} }, + destinations: { href: %r{#{Regexp.escape(link_prefix)}/v3/routes/#{route2.guid}/destinations} }, + domain: { href: %r{#{Regexp.escape(link_prefix)}/v3/domains/#{route2.domain.guid}} } + }, + options: {} + } + end + + context 'when the user is a member in the app space' do + let(:expected_codes_and_responses) do + h = Hash.new( + { code: 200, + response_objects: [route1_json, route2_json] }.freeze + ) + + h['org_auditor'] = { code: 404 } + h['org_billing_manager'] = { code: 404 } + h['no_role'] = { code: 404 } + h + end + + it_behaves_like 'permissions for list endpoint', ALL_PERMISSIONS + end + + context 'ports filter' do + # Don't even think of converting the following hash to symbols ('type' => 'tcp' NOT type: 'tcp'), and you need to set the GUID + let(:router_group) { VCAP::CloudController::RoutingApi::RouterGroup.new({ 'type' => 'tcp', 'reservable_ports' => '7777,8888,9999', 'guid' => 'some-guid' }) } + let(:routing_api_client) { instance_double(VCAP::CloudController::RoutingApi::Client) } + + before do + allow_any_instance_of(CloudController::DependencyLocator).to receive(:routing_api_client).and_return(routing_api_client) + allow(routing_api_client).to receive_messages(enabled?: true, router_group: router_group) + end + + context 'when there are multiple TCP routes with different ports' do + # The following `let`s depend on the above `before do` + let(:domain_tcp) { VCAP::CloudController::SharedDomain.make(router_group_guid: router_group.guid, name: 'my.domain') } + let!(:route_with_ports_0) do + VCAP::CloudController::Route.make(host: '', space: space, domain: domain_tcp, guid: 'route-with-port-0', port: 7777) + end + let!(:route_with_ports_1) do + VCAP::CloudController::Route.make(host: '', space: space, domain: domain_tcp, guid: 'route-with-port-1', port: 8888) + end + let!(:route_with_ports_2) do + VCAP::CloudController::Route.make(host: '', space: space, domain: domain_tcp, guid: 'route-with-port-2', port: 9999) + end + let!(:route_mapping_1) { VCAP::CloudController::RouteMappingModel.make(app: app_model, route: route_with_ports_1, process_type: 'web') } + let!(:route_mapping_2) { VCAP::CloudController::RouteMappingModel.make(app: app_model, route: route_with_ports_2, process_type: 'web') } + + it 'returns routes filtered by ports' do + get "/v3/apps/#{app_model.guid}/routes?ports=7777,8888", nil, admin_header + expect(last_response).to have_status_code(200) + expect(parsed_response['resources'].size).to eq(1) + expect(parsed_response['resources'].first['port']).to eq(route_with_ports_1.port) + end + end + end + + describe 'eager loading' do + it 'eager loads associated resources that the presenter specifies' do + expect(VCAP::CloudController::RouteFetcher).to receive(:fetch).with( + anything, + hash_including(eager_loaded_associations: %i[domain space route_mappings labels annotations]) + ).and_call_original + + get "/v3/apps/#{app_model.guid}/routes", nil, admin_header + expect(last_response).to have_status_code(200) + end + end + end +end From e101b03b9e4cc31fc6e499ac1a47068531f52c10 Mon Sep 17 00:00:00 2001 From: johha Date: Mon, 2 Mar 2026 17:10:24 +0100 Subject: [PATCH 13/20] Convert 14 more message specs to lightweight_spec_helper Add errors_on helper and VCAP::CloudController::Config stub to lightweight_spec_helper to enable more message spec conversions. Converted specs: - deployment_update_message_spec.rb - domain_create_message_spec.rb - droplet_copy_message_spec.rb - droplet_create_message_spec.rb - droplet_update_message_spec.rb - isolation_segment_create_message_spec.rb - isolation_segment_update_message_spec.rb - metadata_base_message_spec.rb - organization_quotas_list_message_spec.rb - service_brokers_list_message_spec.rb - space_quotas_list_message_spec.rb - update_environment_variables_message_spec.rb - users_list_message_spec.rb - validators/url_validator_spec.rb Now 70 message specs use lightweight_spec_helper (vs 82 on spec_helper). --- spec/lightweight_spec_helper.rb | 27 +++++++++++++++++++ .../deployment_update_message_spec.rb | 2 +- .../messages/domain_create_message_spec.rb | 2 +- .../messages/droplet_copy_message_spec.rb | 2 +- .../messages/droplet_create_message_spec.rb | 2 +- .../messages/droplet_update_message_spec.rb | 2 +- .../isolation_segment_create_message_spec.rb | 2 +- .../isolation_segment_update_message_spec.rb | 2 +- .../messages/metadata_base_message_spec.rb | 2 +- .../organization_quotas_list_message_spec.rb | 3 ++- .../service_brokers_list_message_spec.rb | 2 +- .../space_quotas_list_message_spec.rb | 2 +- ...date_environment_variables_message_spec.rb | 2 +- spec/unit/messages/users_list_message_spec.rb | 2 +- .../messages/validators/url_validator_spec.rb | 2 +- 15 files changed, 42 insertions(+), 14 deletions(-) diff --git a/spec/lightweight_spec_helper.rb b/spec/lightweight_spec_helper.rb index 89b08acd13..7a1442eea5 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 816d09de21..bdb41ce7c5 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 f7dae8db28..f83785791e 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 3432244cda..928c7f360b 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 92140109d8..103c75c162 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 f26e8d628e..10ab0c6757 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 21433d30ab..e1d3ab6d62 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 78eabff4d1..6129cf2ccc 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 55b4a55395..ea7f73bbc9 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 609c5baf25..37be96bd2e 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 cd6a898ea0..a01c72162f 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 7d860405fb..2a5bfc5b52 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 3f77983edf..2453a7e3d9 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 2774262293..24b2b5cc8b 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 c6521ec0f6..470699cd03 100644 --- a/spec/unit/messages/validators/url_validator_spec.rb +++ b/spec/unit/messages/validators/url_validator_spec.rb @@ -1,4 +1,4 @@ -require 'spec_helper' +require 'lightweight_spec_helper' require 'messages/validators/url_validator' module VCAP::CloudController::Validators From e85dca1dc0947e2b4b6385f3fd5913b3aefa8eef Mon Sep 17 00:00:00 2001 From: johha Date: Mon, 2 Mar 2026 17:24:39 +0100 Subject: [PATCH 14/20] Fix Config stub to not conflict with spec_helper Only define the minimal Config stub if it's not already defined, to avoid overriding the real Config class when spec_helper is also loaded. --- spec/lightweight_spec_helper.rb | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/spec/lightweight_spec_helper.rb b/spec/lightweight_spec_helper.rb index 7a1442eea5..14a7ed863e 100644 --- a/spec/lightweight_spec_helper.rb +++ b/spec/lightweight_spec_helper.rb @@ -8,13 +8,16 @@ module VCAP module CloudController # Minimal Config stub for message validation specs - class Config - def self.config - @config ||= new - end + # Only define if not already defined (avoid conflict with spec_helper) + unless defined?(Config) + class Config + def self.config + @config ||= new + end - def get(*_keys) - nil + def get(*_keys) + nil + end end end end From 9bc28c18524c0e73a1fa8d0e063c75da863a0e4c Mon Sep 17 00:00:00 2001 From: johha Date: Mon, 2 Mar 2026 17:45:24 +0100 Subject: [PATCH 15/20] Optimize spec_helper: skip Fog reset for most tests Move Fog mock bucket setup to before(:suite) and only reset Fog mocks for tests that explicitly need it (tagged with :fog_reset). This optimization saves ~5.3ms per test by avoiding unnecessary bucket recreation. Only ~6% of tests actually use blobstores, so this benefits the majority of tests. Tests that need a clean Fog state can add `:fog_reset` metadata to opt-in to the previous behavior. --- spec/spec_helper.rb | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 8dd57e0bbf..030aadaa1d 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -159,6 +159,14 @@ # calling this more than once will load tasks again and 'invoke' or 'execute' calls # will call rake tasks multiple times Application.load_tasks + + # Set up Fog mock buckets once at suite start instead of every test + if Fog.mock? + CloudController::DependencyLocator.instance.droplet_blobstore.ensure_bucket_exists + CloudController::DependencyLocator.instance.package_blobstore.ensure_bucket_exists + CloudController::DependencyLocator.instance.global_app_bits_cache.ensure_bucket_exists + CloudController::DependencyLocator.instance.buildpack_blobstore.ensure_bucket_exists + end end rspec_config.before do @@ -169,21 +177,24 @@ TestConfig.context = example.metadata[:job_context] || :api TestConfig.reset - Fog::Mock.reset + VCAP::CloudController::SecurityContext.clear + VCAP::Request.current_id = nil + allow_any_instance_of(VCAP::CloudController::UaaTokenDecoder).to receive(:uaa_issuer).and_return(UAAIssuer::ISSUER) + mock_redis = MockRedis.new + allow(Redis).to receive(:new).and_return(mock_redis) + end + + # Only reset Fog mocks for tests that use blobstores (tagged with :fog_reset) + # This avoids the overhead of clearing and recreating buckets for every test + rspec_config.before(:each, :fog_reset) do + Fog::Mock.reset if Fog.mock? CloudController::DependencyLocator.instance.droplet_blobstore.ensure_bucket_exists CloudController::DependencyLocator.instance.package_blobstore.ensure_bucket_exists CloudController::DependencyLocator.instance.global_app_bits_cache.ensure_bucket_exists CloudController::DependencyLocator.instance.buildpack_blobstore.ensure_bucket_exists end - - VCAP::CloudController::SecurityContext.clear - VCAP::Request.current_id = nil - allow_any_instance_of(VCAP::CloudController::UaaTokenDecoder).to receive(:uaa_issuer).and_return(UAAIssuer::ISSUER) - - mock_redis = MockRedis.new - allow(Redis).to receive(:new).and_return(mock_redis) end rspec_config.around do |example| From daf0d9d8394cb25c9d76c8dc87d34ec38fd7bbd6 Mon Sep 17 00:00:00 2001 From: johha Date: Tue, 3 Mar 2026 08:18:37 +0100 Subject: [PATCH 16/20] Revert "Optimize spec_helper: skip Fog reset for most tests" This reverts commit 9bc28c18524c0e73a1fa8d0e063c75da863a0e4c. --- spec/spec_helper.rb | 27 ++++++++------------------- 1 file changed, 8 insertions(+), 19 deletions(-) diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 030aadaa1d..8dd57e0bbf 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -159,14 +159,6 @@ # calling this more than once will load tasks again and 'invoke' or 'execute' calls # will call rake tasks multiple times Application.load_tasks - - # Set up Fog mock buckets once at suite start instead of every test - if Fog.mock? - CloudController::DependencyLocator.instance.droplet_blobstore.ensure_bucket_exists - CloudController::DependencyLocator.instance.package_blobstore.ensure_bucket_exists - CloudController::DependencyLocator.instance.global_app_bits_cache.ensure_bucket_exists - CloudController::DependencyLocator.instance.buildpack_blobstore.ensure_bucket_exists - end end rspec_config.before do @@ -177,24 +169,21 @@ TestConfig.context = example.metadata[:job_context] || :api TestConfig.reset - VCAP::CloudController::SecurityContext.clear - VCAP::Request.current_id = nil - allow_any_instance_of(VCAP::CloudController::UaaTokenDecoder).to receive(:uaa_issuer).and_return(UAAIssuer::ISSUER) - - mock_redis = MockRedis.new - allow(Redis).to receive(:new).and_return(mock_redis) - end - - # Only reset Fog mocks for tests that use blobstores (tagged with :fog_reset) - # This avoids the overhead of clearing and recreating buckets for every test - rspec_config.before(:each, :fog_reset) do Fog::Mock.reset + if Fog.mock? CloudController::DependencyLocator.instance.droplet_blobstore.ensure_bucket_exists CloudController::DependencyLocator.instance.package_blobstore.ensure_bucket_exists CloudController::DependencyLocator.instance.global_app_bits_cache.ensure_bucket_exists CloudController::DependencyLocator.instance.buildpack_blobstore.ensure_bucket_exists end + + VCAP::CloudController::SecurityContext.clear + VCAP::Request.current_id = nil + allow_any_instance_of(VCAP::CloudController::UaaTokenDecoder).to receive(:uaa_issuer).and_return(UAAIssuer::ISSUER) + + mock_redis = MockRedis.new + allow(Redis).to receive(:new).and_return(mock_redis) end rspec_config.around do |example| From 99b2c57129e7c16b8dda8b13ea7befa85b770023 Mon Sep 17 00:00:00 2001 From: johha Date: Tue, 3 Mar 2026 08:33:25 +0100 Subject: [PATCH 17/20] Use fog_spec_helper for blobstore specs needing clean state Move Fog mock reset from global spec_helper to opt-in fog_spec_helper. This prevents fog reset from running for all specs, which caused conflicts with migration specs that stub Config.get differently. The fog_spec_helper now: - Uses metadata-based hook (:fog_isolation) that only runs for tagged specs - Resets Fog mocks and recreates buckets before each tagged test - Avoids interfering with specs that mock Config differently 24 blobstore-related specs are tagged with :fog_isolation metadata. --- spec/fog_spec_helper.rb | 21 +++++++++++++++++++ spec/spec_helper.rb | 18 ++++++++-------- .../runtime/buildpack_bits_controller_spec.rb | 4 ++-- .../runtime/buildpacks_controller_spec.rb | 4 ++-- .../runtime/stagings_controller_spec.rb | 4 ++-- .../jobs/runtime/blobstore_delete_spec.rb | 4 ++-- .../jobs/runtime/blobstore_upload_spec.rb | 4 ++-- .../runtime/buildpack_cache_cleanup_spec.rb | 4 ++-- .../jobs/v3/buildpack_cache_cleanup_spec.rb | 4 ++-- .../jobs/v3/buildpack_cache_delete_spec.rb | 4 ++-- .../jobs/v3/buildpack_cache_upload_spec.rb | 4 ++-- spec/unit/jobs/v3/droplet_bits_copier_spec.rb | 4 ++-- spec/unit/jobs/v3/droplet_upload_spec.rb | 4 ++-- spec/unit/jobs/v3/package_bits_copier_spec.rb | 4 ++-- .../blobstore/client_provider_spec.rb | 4 ++-- .../blobstore/error_handling_client_spec.rb | 4 ++-- .../blobstore/fog/fog_client_spec.rb | 4 ++-- .../blobstore/retryable_client_spec.rb | 4 ++-- .../blobstore/safe_delete_client_spec.rb | 4 ++-- .../storage_cli/storage_cli_client_spec.rb | 4 ++-- .../blobstore/webdav/dav_client_spec.rb | 4 ++-- .../packager/local_bits_packer_spec.rb | 4 ++-- .../cloud_controller/resource_pool_spec.rb | 4 ++-- .../cloud_controller/upload_buildpack_spec.rb | 4 ++-- .../runtime/buildpack_bits_delete_spec.rb | 4 ++-- spec/unit/models/runtime/buildpack_spec.rb | 4 ++-- 26 files changed, 78 insertions(+), 57 deletions(-) create mode 100644 spec/fog_spec_helper.rb diff --git a/spec/fog_spec_helper.rb b/spec/fog_spec_helper.rb new file mode 100644 index 0000000000..3d90888854 --- /dev/null +++ b/spec/fog_spec_helper.rb @@ -0,0 +1,21 @@ +# Use this helper for specs that need Fog/blobstore functionality with +# a clean state between tests (upload, download, delete operations). +# +# This helper resets Fog mocks and recreates buckets before each test. +# +# For specs that don't need blobstore isolation, use spec_helper instead. + +require 'spec_helper' + +RSpec.configure do |config| + config.before(:each, :fog_isolation) do + Fog::Mock.reset + + if Fog.mock? + CloudController::DependencyLocator.instance.droplet_blobstore.ensure_bucket_exists + CloudController::DependencyLocator.instance.package_blobstore.ensure_bucket_exists + CloudController::DependencyLocator.instance.global_app_bits_cache.ensure_bucket_exists + CloudController::DependencyLocator.instance.buildpack_blobstore.ensure_bucket_exists + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 8dd57e0bbf..e646360cbb 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -159,6 +159,15 @@ # calling this more than once will load tasks again and 'invoke' or 'execute' calls # will call rake tasks multiple times Application.load_tasks + + # Initialize Fog mock buckets once at suite start. + # Tests that need isolated/clean Fog state should use fog_spec_helper. + if Fog.mock? + CloudController::DependencyLocator.instance.droplet_blobstore.ensure_bucket_exists + CloudController::DependencyLocator.instance.package_blobstore.ensure_bucket_exists + CloudController::DependencyLocator.instance.global_app_bits_cache.ensure_bucket_exists + CloudController::DependencyLocator.instance.buildpack_blobstore.ensure_bucket_exists + end end rspec_config.before do @@ -169,15 +178,6 @@ TestConfig.context = example.metadata[:job_context] || :api TestConfig.reset - Fog::Mock.reset - - if Fog.mock? - CloudController::DependencyLocator.instance.droplet_blobstore.ensure_bucket_exists - CloudController::DependencyLocator.instance.package_blobstore.ensure_bucket_exists - CloudController::DependencyLocator.instance.global_app_bits_cache.ensure_bucket_exists - CloudController::DependencyLocator.instance.buildpack_blobstore.ensure_bucket_exists - end - VCAP::CloudController::SecurityContext.clear VCAP::Request.current_id = nil allow_any_instance_of(VCAP::CloudController::UaaTokenDecoder).to receive(:uaa_issuer).and_return(UAAIssuer::ISSUER) diff --git a/spec/unit/controllers/runtime/buildpack_bits_controller_spec.rb b/spec/unit/controllers/runtime/buildpack_bits_controller_spec.rb index a18fb1e724..a372453849 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 0eecd522bd..35f37501bc 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 2be7d38dc8..e4e3324551 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 df7e32fd2f..8e53c2ffdb 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 1e4514b2cd..73c991f571 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 e4c75cb56e..27e98e6272 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 eabeb77fdc..6e82fb763a 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 3b51081a11..ca66770d96 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 ec0a7947a6..67cbf088aa 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 f647632a2e..b4e06595dc 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 08f30bde7b..ae323e81a1 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 46ff1d76dc..f81419e805 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 aef7473375..f3b4843b4e 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 760306a845..53eeb35e97 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 92dc349251..435605fcb1 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 b7e3de44b0..ebf430ca56 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 ae7cbe2b07..159117caf3 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 6fff02091f..bab0cd5c9a 100644 --- a/spec/unit/lib/cloud_controller/blobstore/storage_cli/storage_cli_client_spec.rb +++ b/spec/unit/lib/cloud_controller/blobstore/storage_cli/storage_cli_client_spec.rb @@ -1,9 +1,9 @@ -require 'spec_helper' +require 'fog_spec_helper' require 'cloud_controller/blobstore/storage_cli/storage_cli_client' module CloudController module Blobstore - RSpec.describe StorageCliClient do + RSpec.describe StorageCliClient, :fog_isolation do describe 'client init' do it 'init the correct client when JSON has provider AzureRM' do droplets_cfg = Tempfile.new(['droplets', '.json']) diff --git a/spec/unit/lib/cloud_controller/blobstore/webdav/dav_client_spec.rb b/spec/unit/lib/cloud_controller/blobstore/webdav/dav_client_spec.rb index f4caad00c4..8edb8ada2a 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 1b0846a6e7..ffbd0baa06 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 fab2ad6c25..1711ae4836 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 507f56f9ab..8b3eb2c621 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 17d7ae468d..695b07496b 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 200c8645a1..6fcdf3dd74 100644 --- a/spec/unit/models/runtime/buildpack_spec.rb +++ b/spec/unit/models/runtime/buildpack_spec.rb @@ -1,7 +1,7 @@ -require 'spec_helper' +require 'fog_spec_helper' module VCAP::CloudController - RSpec.describe Buildpack, type: :model do + RSpec.describe Buildpack, :fog_isolation, type: :model do def ordered_buildpacks Buildpack.order(:position).map { |bp| [bp.name, bp.position] } end From 28fb5ac8e071fdcab28c7a1dd987eccc3abb50fc Mon Sep 17 00:00:00 2001 From: johha Date: Tue, 3 Mar 2026 10:11:19 +0100 Subject: [PATCH 18/20] Remove legacy Spork code from spec_helper Spork is no longer used in this project. This removes: - The require 'spork' with rescue block - The shell command that checked for running spork processes - The Spork-related documentation/instructions - The conditional Spork.prefork/each_run blocks The init_block and each_run_block are now called directly. --- spec/spec_helper.rb | 56 ++------------------------------------------- 1 file changed, 2 insertions(+), 54 deletions(-) diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index e646360cbb..81de6bef11 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -2,45 +2,6 @@ require 'rubygems' require 'mock_redis' -begin - require 'spork' - # uncomment the following line to use spork with the debugger - # require 'spork/ext/ruby-debug' - - run_spork = !`ps | grep spork | grep -v grep`.empty? -rescue LoadError - run_spork = false -end - -# --- Instructions --- -# Sort the contents of this file into a Spork.prefork and a Spork.each_run -# block. -# -# The Spork.prefork block is run only once when the spork server is started. -# You typically want to place most of your (slow) initializer code in here, in -# particular, require'ing any 3rd-party gems that you don't normally modify -# during development. -# -# The Spork.each_run block is run each time you run your specs. In case you -# need to load files that tend to change during development, require them here. -# With Rails, your application modules are loaded automatically, so sometimes -# this block can remain empty. -# -# Note: You can modify files loaded *from* the Spork.each_run block without -# restarting the spork server. However, this file itself will not be reloaded, -# so if you change any of the code inside the each_run block, you still need to -# restart the server. In general, if you have non-trivial code in this file, -# it's advisable to move it into a separate file so you can easily edit it -# without restarting spork. (For example, with RSpec, you could move -# non-trivial code into a file spec/support/my_helper.rb, making sure that the -# spec/support/* files are require'd from inside the each_run block.) -# -# Any code that is left outside the two blocks will be run during preforking -# *and* during each_run -- that's probably not what you want. -# -# These instructions should self-destruct in 10 seconds. If they don't, feel -# free to delete them. - init_block = proc do $LOAD_PATH.push(File.expand_path(__dir__)) @@ -219,18 +180,5 @@ end end -if run_spork - Spork.prefork do - # Loading more in this block will cause your tests to run faster. However, - # if you change any configuration or code from libraries loaded here, you'll - # need to restart spork for it to take effect. - init_block.call - end - Spork.each_run do - # This code will be run each time you run your specs. - each_run_block.call - end -else - init_block.call - each_run_block.call -end +init_block.call +each_run_block.call From 34d3c36745553147ce2d9114b2686590c69135a3 Mon Sep 17 00:00:00 2001 From: johha Date: Tue, 3 Mar 2026 10:55:28 +0100 Subject: [PATCH 19/20] Optimize TestConfig.reset to only run when context changes TestConfig.reset is expensive (~1.9ms per call) as it reloads config from file and resets all dependencies. Previously it ran for every test, but only ~57 specs use non-default job_context. Now TestConfig.reset only runs when the context actually changes: - First test: reset happens (context transitions from nil to :api) - Subsequent :api tests: no reset needed - Tests with :worker or :clock context: reset only on context change This saves ~1.9ms per test for the vast majority of specs. --- spec/spec_helper.rb | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 81de6bef11..7586fa878a 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -136,8 +136,11 @@ Sequel::Deprecation.output = StringIO.new Sequel::Deprecation.backtrace_filter = 5 - TestConfig.context = example.metadata[:job_context] || :api - TestConfig.reset + new_context = example.metadata[:job_context] || :api + if TestConfig.context != new_context + TestConfig.context = new_context + TestConfig.reset + end VCAP::CloudController::SecurityContext.clear VCAP::Request.current_id = nil From ca829221997c877c11ded27c787aa329a8ec2b62 Mon Sep 17 00:00:00 2001 From: johha Date: Tue, 3 Mar 2026 13:17:50 +0100 Subject: [PATCH 20/20] Revert "Optimize TestConfig.reset to only run when context changes" This reverts the optimization because it broke test isolation. Tests use Config.config.set() to override config values, and without TestConfig.reset between tests, these changes persist and pollute subsequent tests. For example, tests setting max_annotations_per_resource=1 caused other tests to fail with "AnnotationLimitExceeded" errors. --- spec/spec_helper.rb | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 7586fa878a..81de6bef11 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -136,11 +136,8 @@ Sequel::Deprecation.output = StringIO.new Sequel::Deprecation.backtrace_filter = 5 - new_context = example.metadata[:job_context] || :api - if TestConfig.context != new_context - TestConfig.context = new_context - TestConfig.reset - end + TestConfig.context = example.metadata[:job_context] || :api + TestConfig.reset VCAP::CloudController::SecurityContext.clear VCAP::Request.current_id = nil