diff --git a/google/cloud/internal/json_parsing.cc b/google/cloud/internal/json_parsing.cc index b1e42321debe5..6ef260fe38c35 100644 --- a/google/cloud/internal/json_parsing.cc +++ b/google/cloud/internal/json_parsing.cc @@ -63,6 +63,19 @@ StatusOr ValidateIntField(nlohmann::json const& json, return it->get(); } +StatusOr> ValidateStringArrayField( + nlohmann::json const& json, absl::string_view name, + absl::string_view object_name, internal::ErrorContext const& ec) { + auto it = json.find(std::string{name}); + if (it == json.end()) return MissingFieldError(name, object_name, ec); + if (!it->is_array()) return InvalidTypeError(name, object_name, ec); + if (!std::all_of(it->begin(), it->end(), + [](nlohmann::json const& e) { return e.is_string(); })) { + return InvalidTypeError(name, object_name, ec); + } + return it->get>(); +} + Status MissingFieldError(absl::string_view name, absl::string_view object_name, internal::ErrorContext const& ec) { return InvalidArgumentError( diff --git a/google/cloud/internal/json_parsing.h b/google/cloud/internal/json_parsing.h index 3549d73326f92..66676f6811f4d 100644 --- a/google/cloud/internal/json_parsing.h +++ b/google/cloud/internal/json_parsing.h @@ -57,6 +57,12 @@ StatusOr ValidateIntField(nlohmann::json const& json, std::int32_t default_value, internal::ErrorContext const& ec); +/// Returns the string values for `json[name]` (which must exist) or a +/// descriptive error. +StatusOr> ValidateStringArrayField( + nlohmann::json const& json, absl::string_view name, + absl::string_view object_name, internal::ErrorContext const& ec); + /// Use when a JSON field cannot be found but is required. Status MissingFieldError(absl::string_view name, absl::string_view object_name, internal::ErrorContext const& ec); diff --git a/google/cloud/internal/json_parsing_test.cc b/google/cloud/internal/json_parsing_test.cc index 5fa05b8c8dfe9..10f277de1a1d3 100644 --- a/google/cloud/internal/json_parsing_test.cc +++ b/google/cloud/internal/json_parsing_test.cc @@ -25,10 +25,11 @@ namespace { using ::google::cloud::testing_util::StatusIs; using ::testing::AllOf; using ::testing::Contains; +using ::testing::ElementsAre; using ::testing::HasSubstr; using ::testing::Pair; -TEST(ExternalAccountParsing, ValidateStringFieldSuccess) { +TEST(JsonParsingTest, ValidateStringFieldSuccess) { auto const json = nlohmann::json{{"someField", "value"}}; auto actual = ValidateStringField( json, "someField", "test-object", @@ -37,7 +38,7 @@ TEST(ExternalAccountParsing, ValidateStringFieldSuccess) { EXPECT_EQ(*actual, "value"); } -TEST(ExternalAccountParsing, ValidateStringFieldMissing) { +TEST(JsonParsingTest, ValidateStringFieldMissing) { auto const json = nlohmann::json{{"some-field", "value"}}; auto actual = ValidateStringField( json, "missingField", "test-object", @@ -51,7 +52,7 @@ TEST(ExternalAccountParsing, ValidateStringFieldMissing) { Contains(Pair("origin", "test"))); } -TEST(ExternalAccountParsing, ValidateStringFieldNotString) { +TEST(JsonParsingTest, ValidateStringFieldNotString) { auto const json = nlohmann::json{{"some-field", "value"}, {"wrongType", true}}; auto actual = ValidateStringField( @@ -66,7 +67,7 @@ TEST(ExternalAccountParsing, ValidateStringFieldNotString) { Contains(Pair("origin", "test"))); } -TEST(ExternalAccountParsing, ValidateStringFieldDefaultSuccess) { +TEST(JsonParsingTest, ValidateStringFieldDefaultSuccess) { auto const json = nlohmann::json{{"someField", "value"}}; auto actual = ValidateStringField( json, "someField", "test-object", "default-value", @@ -75,7 +76,7 @@ TEST(ExternalAccountParsing, ValidateStringFieldDefaultSuccess) { EXPECT_EQ(*actual, "value"); } -TEST(ExternalAccountParsing, ValidateStringFieldDefaultMissing) { +TEST(JsonParsingTest, ValidateStringFieldDefaultMissing) { auto const json = nlohmann::json{{"anotherField", "value"}}; auto actual = ValidateStringField( json, "someField", "test-object", "default-value", @@ -84,7 +85,7 @@ TEST(ExternalAccountParsing, ValidateStringFieldDefaultMissing) { EXPECT_EQ(*actual, "default-value"); } -TEST(ExternalAccountParsing, ValidateStringFieldDefaultNotInt) { +TEST(JsonParsingTest, ValidateStringFieldDefaultNotInt) { auto const json = nlohmann::json{{"some-field", "value"}, {"wrongType", true}}; auto actual = ValidateStringField( @@ -99,7 +100,7 @@ TEST(ExternalAccountParsing, ValidateStringFieldDefaultNotInt) { Contains(Pair("origin", "test"))); } -TEST(ExternalAccountParsing, ValidateIntFieldSuccess) { +TEST(JsonParsingTest, ValidateIntFieldSuccess) { auto const json = nlohmann::json{{"someField", 42}}; auto actual = ValidateIntField( json, "someField", "test-object", @@ -108,7 +109,7 @@ TEST(ExternalAccountParsing, ValidateIntFieldSuccess) { EXPECT_EQ(*actual, 42); } -TEST(ExternalAccountParsing, ValidateIntFieldMissing) { +TEST(JsonParsingTest, ValidateIntFieldMissing) { auto const json = nlohmann::json{{"some-field", 42}}; auto actual = ValidateIntField( json, "missingField", "test-object", @@ -122,7 +123,7 @@ TEST(ExternalAccountParsing, ValidateIntFieldMissing) { Contains(Pair("origin", "test"))); } -TEST(ExternalAccountParsing, ValidateIntFieldNotString) { +TEST(JsonParsingTest, ValidateIntFieldNotString) { auto const json = nlohmann::json{{"some-field", "value"}, {"wrongType", true}}; auto actual = ValidateIntField( @@ -137,7 +138,7 @@ TEST(ExternalAccountParsing, ValidateIntFieldNotString) { Contains(Pair("origin", "test"))); } -TEST(ExternalAccountParsing, ValidateIntFieldDefaultSuccess) { +TEST(JsonParsingTest, ValidateIntFieldDefaultSuccess) { auto const json = nlohmann::json{{"someField", 42}}; auto actual = ValidateIntField( json, "someField", "test-object", 42, @@ -146,7 +147,7 @@ TEST(ExternalAccountParsing, ValidateIntFieldDefaultSuccess) { EXPECT_EQ(*actual, 42); } -TEST(ExternalAccountParsing, ValidateIntFieldDefaultMissing) { +TEST(JsonParsingTest, ValidateIntFieldDefaultMissing) { auto const json = nlohmann::json{{"anotherField", "value"}}; auto actual = ValidateIntField( json, "someField", "test-object", 42, @@ -155,7 +156,7 @@ TEST(ExternalAccountParsing, ValidateIntFieldDefaultMissing) { EXPECT_EQ(*actual, 42); } -TEST(ExternalAccountParsing, ValidateIntFieldDefaultNotString) { +TEST(JsonParsingTest, ValidateIntFieldDefaultNotString) { auto const json = nlohmann::json{{"some-field", "value"}, {"wrongType", true}}; auto actual = ValidateIntField( @@ -170,6 +171,43 @@ TEST(ExternalAccountParsing, ValidateIntFieldDefaultNotString) { Contains(Pair("origin", "test"))); } +TEST(JsonParsingTest, ValidateStringArrayFieldSuccess) { + auto const json = nlohmann::json{{"someField", {"value1", "value2"}}}; + auto actual = ValidateStringArrayField( + json, "someField", "test-object", + internal::ErrorContext({{"origin", "test"}, {"filename", "/dev/null"}})); + ASSERT_STATUS_OK(actual); + EXPECT_THAT(*actual, ElementsAre("value1", "value2")); +} + +TEST(JsonParsingTest, ValidateStringArrayFieldMissing) { + auto const json = nlohmann::json{{"some-field", {"value1", "value2"}}}; + auto actual = ValidateStringArrayField( + json, "missingField", "test-object", + internal::ErrorContext({{"origin", "test"}, {"filename", "/dev/null"}})); + EXPECT_THAT(actual, StatusIs(StatusCode::kInvalidArgument, + AllOf(HasSubstr("missingField"), + HasSubstr("test-object")))); + EXPECT_THAT(actual.status().error_info().metadata(), + Contains(Pair("filename", "/dev/null"))); + EXPECT_THAT(actual.status().error_info().metadata(), + Contains(Pair("origin", "test"))); +} + +TEST(JsonParsingTest, ValidateStringArrayFieldNotString) { + auto const json = nlohmann::json({"wrongType", {"value1", true}}); + auto actual = ValidateStringArrayField( + json, "wrongType", "test-object", + internal::ErrorContext({{"origin", "test"}, {"filename", "/dev/null"}})); + EXPECT_THAT(actual, StatusIs(StatusCode::kInvalidArgument, + AllOf(HasSubstr("wrongType"), + HasSubstr("test-object")))); + EXPECT_THAT(actual.status().error_info().metadata(), + Contains(Pair("filename", "/dev/null"))); + EXPECT_THAT(actual.status().error_info().metadata(), + Contains(Pair("origin", "test"))); +} + } // namespace GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_END } // namespace oauth2_internal diff --git a/google/cloud/internal/oauth2_impersonate_service_account_credentials_test.cc b/google/cloud/internal/oauth2_impersonate_service_account_credentials_test.cc index 5747814b5147e..19da1679f3257 100644 --- a/google/cloud/internal/oauth2_impersonate_service_account_credentials_test.cc +++ b/google/cloud/internal/oauth2_impersonate_service_account_credentials_test.cc @@ -155,6 +155,12 @@ class MockMinimalIamCredentialsRest : public MinimalIamCredentialsRest { public: MOCK_METHOD(StatusOr, GenerateAccessToken, (GenerateAccessTokenRequest const&), (override)); + MOCK_METHOD(StatusOr, AllowedLocations, + (ServiceAccountAllowedLocationsRequest const&), (override)); + MOCK_METHOD(StatusOr, AllowedLocations, + (WorkloadIdentityAllowedLocationsRequest const&), (override)); + MOCK_METHOD(StatusOr, AllowedLocations, + (WorkforceIdentityAllowedLocationsRequest const&), (override)); MOCK_METHOD(StatusOr, universe_domain, (Options const& options), (override, const)); }; diff --git a/google/cloud/internal/oauth2_minimal_iam_credentials_rest.cc b/google/cloud/internal/oauth2_minimal_iam_credentials_rest.cc index c2d1b53fe751d..9cea2ea5e0d8b 100644 --- a/google/cloud/internal/oauth2_minimal_iam_credentials_rest.cc +++ b/google/cloud/internal/oauth2_minimal_iam_credentials_rest.cc @@ -33,6 +33,15 @@ namespace google { namespace cloud { namespace oauth2_internal { GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_BEGIN +namespace { + +std::string IamCredentialsEndpoint( + StatusOr const& universe_domain) { + return absl::StrCat("https://iamcredentials.", + universe_domain ? *universe_domain : "googleapis.com"); +} + +} // namespace using ::google::cloud::internal::InvalidArgumentError; @@ -73,12 +82,60 @@ MinimalIamCredentialsRestStub::GenerateAccessToken( std::string MinimalIamCredentialsRestStub::MakeRequestPath( GenerateAccessTokenRequest const& request) const { - auto ud = universe_domain(Options{}); - return absl::StrCat("https://iamcredentials.", ud ? *ud : "googleapis.com", + return absl::StrCat(IamCredentialsEndpoint(universe_domain(Options{})), "/v1/projects/-/serviceAccounts/", request.service_account, ":generateAccessToken"); } +StatusOr +MinimalIamCredentialsRestStub::AllowedLocationsHelper(std::string path) { + auto authorization_header = + credentials_->Authorization(std::chrono::system_clock::now()); + if (!authorization_header) return std::move(authorization_header).status(); + rest_internal::RestRequest rest_request; + rest_request.AddHeader(*std::move(authorization_header)); + rest_request.SetPath(std::move(path)); + + auto client = client_factory_(options_); + rest_internal::RestContext context; + auto response = client->Get(context, rest_request); + if (!response) return std::move(response).status(); + return ParseAllowedLocationsResponse( + **response, + internal::ErrorContext( + {{"gcloud-cpp.root.class", "MinimalIamCredentialsRestStub"}, + {"gcloud-cpp.root.function", __func__}, + {"path", rest_request.path()}})); +} + +StatusOr +MinimalIamCredentialsRestStub::AllowedLocations( + ServiceAccountAllowedLocationsRequest const& request) { + auto path = absl::StrCat(IamCredentialsEndpoint(universe_domain(Options{})), + "/v1/projects/-/serviceAccounts/", + request.service_account_email, "/allowedLocations"); + return AllowedLocationsHelper(std::move(path)); +} + +StatusOr +MinimalIamCredentialsRestStub::AllowedLocations( + WorkloadIdentityAllowedLocationsRequest const& request) { + auto path = absl::StrCat(IamCredentialsEndpoint(universe_domain(Options{})), + "/v1/projects/", request.project_id, + "/locations/global/workloadIdentityPools/", + request.pool_id, "/allowedLocations"); + return AllowedLocationsHelper(std::move(path)); +} + +StatusOr +MinimalIamCredentialsRestStub::AllowedLocations( + WorkforceIdentityAllowedLocationsRequest const& request) { + auto path = absl::StrCat(IamCredentialsEndpoint(universe_domain(Options{})), + "/v1/locations/global/workforcePools/", + request.pool_id, "/allowedLocations"); + return AllowedLocationsHelper(std::move(path)); +} + MinimalIamCredentialsRestLogging::MinimalIamCredentialsRestLogging( std::shared_ptr child) : child_(std::move(child)) {} @@ -104,6 +161,53 @@ MinimalIamCredentialsRestLogging::GenerateAccessToken( return response; } +StatusOr +MinimalIamCredentialsRestLogging::AllowedLocations( + ServiceAccountAllowedLocationsRequest const& request) { + GCP_LOG(INFO) << __func__ << "() << {service_account_email=" + << request.service_account_email << "}"; + auto response = child_->AllowedLocations(request); + if (!response) { + GCP_LOG(INFO) << __func__ << "() >> status={" << response.status() << "}"; + return response; + } + GCP_LOG(INFO) << __func__ << "() >> response={locations=" + << absl::StrJoin(response->locations, ",") + << ", encoded_locations=" << response->encoded_locations << "}"; + return response; +} + +StatusOr +MinimalIamCredentialsRestLogging::AllowedLocations( + WorkloadIdentityAllowedLocationsRequest const& request) { + GCP_LOG(INFO) << __func__ << "() << {project_id=" << request.project_id + << ", pool_id=" << request.pool_id << "}"; + auto response = child_->AllowedLocations(request); + if (!response) { + GCP_LOG(INFO) << __func__ << "() >> status={" << response.status() << "}"; + return response; + } + GCP_LOG(INFO) << __func__ << "() >> response={locations=" + << absl::StrJoin(response->locations, ",") + << ", encoded_locations=" << response->encoded_locations << "}"; + return response; +} + +StatusOr +MinimalIamCredentialsRestLogging::AllowedLocations( + WorkforceIdentityAllowedLocationsRequest const& request) { + GCP_LOG(INFO) << __func__ << "() << {pool_id=" << request.pool_id << "}"; + auto response = child_->AllowedLocations(request); + if (!response) { + GCP_LOG(INFO) << __func__ << "() >> status={" << response.status() << "}"; + return response; + } + GCP_LOG(INFO) << __func__ << "() >> response={locations=" + << absl::StrJoin(response->locations, ",") + << ", encoded_locations=" << response->encoded_locations << "}"; + return response; +} + StatusOr ParseGenerateAccessTokenResponse( rest_internal::RestResponse& response, google::cloud::internal::ErrorContext const& ec) { @@ -132,6 +236,29 @@ StatusOr ParseGenerateAccessTokenResponse( return google::cloud::AccessToken{*std::move(token), *expire_time}; } +StatusOr ParseAllowedLocationsResponse( + rest_internal::RestResponse& response, + google::cloud::internal::ErrorContext const& ec) { + if (IsHttpError(response)) return AsStatus(std::move(response)); + auto response_payload = + rest_internal::ReadAll(std::move(response).ExtractPayload()); + if (!response_payload) return std::move(response_payload).status(); + auto parsed = nlohmann::json::parse(*response_payload, nullptr, false); + if (!parsed.is_object()) { + return InvalidArgumentError("cannot parse response as a JSON object", + GCP_ERROR_INFO().WithContext(ec)); + } + auto locations = ValidateStringArrayField(parsed, "locations", + "AllowedLocations() response", ec); + if (!locations) return std::move(locations).status(); + + auto encoded_locations = ValidateStringField( + parsed, "encodedLocations", "AllowedLocations() response", ec); + if (!encoded_locations) return std::move(encoded_locations).status(); + return AllowedLocationsResponse{*std::move(locations), + *std::move(encoded_locations)}; +} + std::shared_ptr MakeMinimalIamCredentialsRestStub( std::shared_ptr credentials, Options options, HttpClientFactory client_factory) { diff --git a/google/cloud/internal/oauth2_minimal_iam_credentials_rest.h b/google/cloud/internal/oauth2_minimal_iam_credentials_rest.h index 0c1a8e241c5f9..fda4f1f9bb4e6 100644 --- a/google/cloud/internal/oauth2_minimal_iam_credentials_rest.h +++ b/google/cloud/internal/oauth2_minimal_iam_credentials_rest.h @@ -39,11 +39,33 @@ struct GenerateAccessTokenRequest { std::vector delegates; }; +struct ServiceAccountAllowedLocationsRequest { + std::string service_account_email; +}; + +struct WorkloadIdentityAllowedLocationsRequest { + std::string project_id; + std::string pool_id; +}; + +struct WorkforceIdentityAllowedLocationsRequest { + std::string pool_id; +}; + +struct AllowedLocationsResponse { + std::vector locations; + std::string encoded_locations; +}; + /// Parse the HTTP response from a `GenerateAccessToken()` call. StatusOr ParseGenerateAccessTokenResponse( rest_internal::RestResponse& response, google::cloud::internal::ErrorContext const& ec); +StatusOr ParseAllowedLocationsResponse( + rest_internal::RestResponse& response, + google::cloud::internal::ErrorContext const& ec); + /** * Wrapper for IAM Credentials intended for use with * `ImpersonateServiceAccountCredentials`. @@ -55,6 +77,13 @@ class MinimalIamCredentialsRest { virtual StatusOr GenerateAccessToken( GenerateAccessTokenRequest const& request) = 0; + virtual StatusOr AllowedLocations( + ServiceAccountAllowedLocationsRequest const& request) = 0; + virtual StatusOr AllowedLocations( + WorkloadIdentityAllowedLocationsRequest const& request) = 0; + virtual StatusOr AllowedLocations( + WorkforceIdentityAllowedLocationsRequest const& request) = 0; + virtual StatusOr universe_domain( Options const& options) const = 0; }; @@ -78,12 +107,20 @@ class MinimalIamCredentialsRestStub : public MinimalIamCredentialsRest { StatusOr GenerateAccessToken( GenerateAccessTokenRequest const& request) override; + StatusOr AllowedLocations( + ServiceAccountAllowedLocationsRequest const& request) override; + StatusOr AllowedLocations( + WorkloadIdentityAllowedLocationsRequest const& request) override; + StatusOr AllowedLocations( + WorkforceIdentityAllowedLocationsRequest const& request) override; + StatusOr universe_domain(Options const& options) const override { return credentials_->universe_domain(options); } private: std::string MakeRequestPath(GenerateAccessTokenRequest const& request) const; + StatusOr AllowedLocationsHelper(std::string path); std::shared_ptr credentials_; Options options_; @@ -101,6 +138,13 @@ class MinimalIamCredentialsRestLogging : public MinimalIamCredentialsRest { StatusOr GenerateAccessToken( GenerateAccessTokenRequest const& request) override; + StatusOr AllowedLocations( + ServiceAccountAllowedLocationsRequest const& request) override; + StatusOr AllowedLocations( + WorkloadIdentityAllowedLocationsRequest const& request) override; + StatusOr AllowedLocations( + WorkforceIdentityAllowedLocationsRequest const& request) override; + StatusOr universe_domain(Options const& options) const override { return child_->universe_domain(options); } diff --git a/google/cloud/internal/oauth2_minimal_iam_credentials_rest_test.cc b/google/cloud/internal/oauth2_minimal_iam_credentials_rest_test.cc index a84e856cec02b..7a81fe2ad43f2 100644 --- a/google/cloud/internal/oauth2_minimal_iam_credentials_rest_test.cc +++ b/google/cloud/internal/oauth2_minimal_iam_credentials_rest_test.cc @@ -44,8 +44,11 @@ using ::google::cloud::testing_util::StatusIs; using ::testing::_; using ::testing::A; using ::testing::ByMove; +using ::testing::ElementsAre; using ::testing::Eq; using ::testing::HasSubstr; +using ::testing::IsSupersetOf; +using ::testing::Pair; using ::testing::Return; class MockCredentials : public google::cloud::oauth2_internal::Credentials { @@ -250,6 +253,8 @@ TEST(MinimalIamCredentialsRestTest, GenerateAccessTokenSuccess) { EXPECT_CALL(*mock_credentials, GetToken).WillOnce([lifetime](auto tp) { return AccessToken{"test-token", tp + lifetime}; }); + EXPECT_CALL(*mock_credentials, universe_domain) + .WillOnce(::testing::Return(StatusOr{"googleapis.com"})); auto stub = MinimalIamCredentialsRestStub(std::move(mock_credentials), Options{}, @@ -341,6 +346,220 @@ TEST(MinimalIamCredentialsRestTest, GetUniverseDomainFromCredentials) { IsOkAndHolds(kExpectedUniverseDomain)); } +TEST(MinimalIamCredentialsRestTest, AllowedLocationsAuthorizationFailure) { + auto mock_credentials = std::make_shared(); + EXPECT_CALL(*mock_credentials, GetToken).WillOnce([] { + return Status(StatusCode::kPermissionDenied, "Permission Denied"); + }); + EXPECT_CALL(*mock_credentials, universe_domain) + .WillOnce(::testing::Return(StatusOr{"googleapis.com"})); + + MockHttpClientFactory mock_client_factory; + EXPECT_CALL(mock_client_factory, Call).Times(0); + auto stub = MinimalIamCredentialsRestStub( + std::move(mock_credentials), {}, mock_client_factory.AsStdFunction()); + ServiceAccountAllowedLocationsRequest request; + request.service_account_email = "foo@somewhere.com"; + auto access_token = stub.AllowedLocations(request); + EXPECT_THAT(access_token, StatusIs(StatusCode::kPermissionDenied)); +} + +TEST(MinimalIamCredentialsRestTest, AllowedLocationsMalformedResponseFailure) { + std::string service_account = "foo@somewhere.com"; + std::chrono::seconds lifetime(3600); + std::string response = R"""({})"""; + MockHttpClientFactory mock_client_factory; + EXPECT_CALL(mock_client_factory, Call).WillOnce([=](Options const&) { + auto client = std::make_unique(); + EXPECT_CALL(*client, Get) + .WillOnce([&](RestContext&, RestRequest const& request) { + auto mock_response = std::make_unique(); + EXPECT_CALL(*mock_response, StatusCode) + .WillRepeatedly(Return(rest_internal::HttpStatusCode::kOk)); + EXPECT_CALL(std::move(*mock_response), ExtractPayload) + .WillOnce([response] { + return testing_util::MakeMockHttpPayloadSuccess(response); + }); + EXPECT_THAT( + request.path(), + Eq(absl::StrCat("https://iamcredentials.googleapis.com/v1/", + "projects/-/serviceAccounts/", service_account, + "/allowedLocations"))); + return std::unique_ptr(std::move(mock_response)); + }); + return std::unique_ptr(std::move(client)); + }); + auto mock_credentials = std::make_shared(); + EXPECT_CALL(*mock_credentials, GetToken).WillOnce([lifetime](auto tp) { + return AccessToken{"test-token", tp + lifetime}; + }); + EXPECT_CALL(*mock_credentials, universe_domain) + .WillOnce(::testing::Return(StatusOr{"googleapis.com"})); + + auto stub = + MinimalIamCredentialsRestStub(std::move(mock_credentials), Options{}, + mock_client_factory.AsStdFunction()); + ServiceAccountAllowedLocationsRequest request; + request.service_account_email = service_account; + auto allowed_locations = stub.AllowedLocations(request); + EXPECT_THAT(allowed_locations, StatusIs(StatusCode::kInvalidArgument)); + EXPECT_THAT( + allowed_locations.status().error_info().metadata(), + IsSupersetOf( + {Pair("gcloud-cpp.root.class", "MinimalIamCredentialsRestStub"), + Pair("gcloud-cpp.root.function", "AllowedLocationsHelper"), + Pair("path", + "https://iamcredentials.googleapis.com/v1/projects/-/" + "serviceAccounts/foo@somewhere.com/allowedLocations")})); +} + +TEST(MinimalIamCredentialsRestTest, ServiceAccountAllowedLocations) { + std::string service_account = "foo@somewhere.com"; + std::chrono::seconds lifetime(3600); + std::string response = R"""({ + "locations": [ + "us-central1", "us-east1", "europe-west1", "asia-east1" + ], + "encodedLocations" : "0xA30"})"""; + MockHttpClientFactory mock_client_factory; + EXPECT_CALL(mock_client_factory, Call).WillOnce([=](Options const&) { + auto client = std::make_unique(); + EXPECT_CALL(*client, Get) + .WillOnce([&](RestContext&, RestRequest const& request) { + auto mock_response = std::make_unique(); + EXPECT_CALL(*mock_response, StatusCode) + .WillRepeatedly(Return(rest_internal::HttpStatusCode::kOk)); + EXPECT_CALL(std::move(*mock_response), ExtractPayload) + .WillOnce([response] { + return testing_util::MakeMockHttpPayloadSuccess(response); + }); + EXPECT_THAT( + request.path(), + Eq(absl::StrCat("https://iamcredentials.googleapis.com/v1/", + "projects/-/serviceAccounts/", service_account, + "/allowedLocations"))); + return std::unique_ptr(std::move(mock_response)); + }); + return std::unique_ptr(std::move(client)); + }); + auto mock_credentials = std::make_shared(); + EXPECT_CALL(*mock_credentials, GetToken).WillOnce([lifetime](auto tp) { + return AccessToken{"test-token", tp + lifetime}; + }); + EXPECT_CALL(*mock_credentials, universe_domain) + .WillOnce(::testing::Return(StatusOr{"googleapis.com"})); + + auto stub = + MinimalIamCredentialsRestStub(std::move(mock_credentials), Options{}, + mock_client_factory.AsStdFunction()); + ServiceAccountAllowedLocationsRequest request; + request.service_account_email = service_account; + auto allowed_locations = stub.AllowedLocations(request); + EXPECT_THAT( + allowed_locations->locations, + ElementsAre("us-central1", "us-east1", "europe-west1", "asia-east1")); + EXPECT_THAT(allowed_locations->encoded_locations, Eq("0xA30")); +} + +TEST(MinimalIamCredentialsRestTest, WorkloadIdentityAllowedLocations) { + std::string project_id = "my-project"; + std::string pool_id = "my-pool"; + std::chrono::seconds lifetime(3600); + std::string response = R"""({ + "locations": [ + "us-central1", "us-east1", "europe-west1", "asia-east1" + ], + "encodedLocations" : "0xA30"})"""; + MockHttpClientFactory mock_client_factory; + EXPECT_CALL(mock_client_factory, Call).WillOnce([=](Options const&) { + auto client = std::make_unique(); + EXPECT_CALL(*client, Get) + .WillOnce([&](RestContext&, RestRequest const& request) { + auto mock_response = std::make_unique(); + EXPECT_CALL(*mock_response, StatusCode) + .WillRepeatedly(Return(rest_internal::HttpStatusCode::kOk)); + EXPECT_CALL(std::move(*mock_response), ExtractPayload) + .WillOnce([response] { + return testing_util::MakeMockHttpPayloadSuccess(response); + }); + EXPECT_THAT( + request.path(), + Eq(absl::StrCat("https://iamcredentials.googleapis.com/v1/", + "projects/", project_id, + "/locations/global/workloadIdentityPools/", + pool_id, "/allowedLocations"))); + return std::unique_ptr(std::move(mock_response)); + }); + return std::unique_ptr(std::move(client)); + }); + auto mock_credentials = std::make_shared(); + EXPECT_CALL(*mock_credentials, GetToken).WillOnce([lifetime](auto tp) { + return AccessToken{"test-token", tp + lifetime}; + }); + EXPECT_CALL(*mock_credentials, universe_domain) + .WillOnce(::testing::Return(StatusOr{"googleapis.com"})); + + auto stub = + MinimalIamCredentialsRestStub(std::move(mock_credentials), Options{}, + mock_client_factory.AsStdFunction()); + WorkloadIdentityAllowedLocationsRequest request; + request.project_id = project_id; + request.pool_id = pool_id; + auto allowed_locations = stub.AllowedLocations(request); + EXPECT_THAT( + allowed_locations->locations, + ElementsAre("us-central1", "us-east1", "europe-west1", "asia-east1")); + EXPECT_THAT(allowed_locations->encoded_locations, Eq("0xA30")); +} + +TEST(MinimalIamCredentialsRestTest, WorkforceIdentityAllowedLocations) { + std::string pool_id = "my-pool"; + std::chrono::seconds lifetime(3600); + std::string response = R"""({ + "locations": [ + "us-central1", "us-east1", "europe-west1", "asia-east1" + ], + "encodedLocations" : "0xA30"})"""; + MockHttpClientFactory mock_client_factory; + EXPECT_CALL(mock_client_factory, Call).WillOnce([=](Options const&) { + auto client = std::make_unique(); + EXPECT_CALL(*client, Get) + .WillOnce([&](RestContext&, RestRequest const& request) { + auto mock_response = std::make_unique(); + EXPECT_CALL(*mock_response, StatusCode) + .WillRepeatedly(Return(rest_internal::HttpStatusCode::kOk)); + EXPECT_CALL(std::move(*mock_response), ExtractPayload) + .WillOnce([response] { + return testing_util::MakeMockHttpPayloadSuccess(response); + }); + EXPECT_THAT( + request.path(), + Eq(absl::StrCat("https://iamcredentials.googleapis.com/v1/", + "locations/global/workforcePools/", pool_id, + "/allowedLocations"))); + return std::unique_ptr(std::move(mock_response)); + }); + return std::unique_ptr(std::move(client)); + }); + auto mock_credentials = std::make_shared(); + EXPECT_CALL(*mock_credentials, GetToken).WillOnce([lifetime](auto tp) { + return AccessToken{"test-token", tp + lifetime}; + }); + EXPECT_CALL(*mock_credentials, universe_domain) + .WillOnce(::testing::Return(StatusOr{"googleapis.com"})); + + auto stub = + MinimalIamCredentialsRestStub(std::move(mock_credentials), Options{}, + mock_client_factory.AsStdFunction()); + WorkforceIdentityAllowedLocationsRequest request; + request.pool_id = pool_id; + auto allowed_locations = stub.AllowedLocations(request); + EXPECT_THAT( + allowed_locations->locations, + ElementsAre("us-central1", "us-east1", "europe-west1", "asia-east1")); + EXPECT_THAT(allowed_locations->encoded_locations, Eq("0xA30")); +} + } // namespace GOOGLE_CLOUD_CPP_INLINE_NAMESPACE_END } // namespace oauth2_internal