7474import org .junit .jupiter .params .ParameterizedTest ;
7575import org .junit .jupiter .params .provider .Arguments ;
7676import org .junit .jupiter .params .provider .MethodSource ;
77+ import org .junitpioneer .jupiter .ClearSystemProperty ;
7778import org .mockito .ArgumentCaptor ;
7879import org .mockito .Captor ;
7980import org .mockito .Mock ;
@@ -456,6 +457,86 @@ void testQuotaProjectBehavior(QuotaProjectIdTestBehavior testCase) throws IOExce
456457 }
457458 }
458459
460+ @ ParameterizedTest
461+ @ MethodSource ("provideProjectIdBehaviorTestCases" )
462+ @ ClearSystemProperty (key = "google.cloud.project" )
463+ @ ClearSystemProperty (key = "google.otel.auth.target.signals" )
464+ @ SuppressWarnings ("CannotMockMethod" )
465+ void testProjectIdBehavior (ProjectIdTestBehavior testCase ) throws IOException {
466+
467+ // configure environment according to test case
468+ String userSpecifiedProjectId = testCase .getUserSpecifiedProjectId ();
469+ if (userSpecifiedProjectId != null ) {
470+ System .setProperty (
471+ ConfigurableOption .GOOGLE_CLOUD_PROJECT .getSystemProperty (), userSpecifiedProjectId );
472+ }
473+ System .setProperty (
474+ ConfigurableOption .GOOGLE_OTEL_AUTH_TARGET_SIGNALS .getSystemProperty (), SIGNAL_TYPE_TRACES );
475+
476+ // prepare request metadata (may or may not be called depending on test scenario)
477+ AccessToken fakeAccessToken = new AccessToken ("fake" , Date .from (Instant .now ()));
478+ ImmutableMap <String , List <String >> mockedRequestMetadata =
479+ ImmutableMap .of (
480+ "Authorization" ,
481+ Collections .singletonList ("Bearer " + fakeAccessToken .getTokenValue ()));
482+ Mockito .lenient ()
483+ .when (mockedGoogleCredentials .getRequestMetadata ())
484+ .thenReturn (mockedRequestMetadata );
485+
486+ // only mock getProjectId() if it will be called (i.e., user didn't specify project ID)
487+ boolean shouldFallbackToCredentials =
488+ userSpecifiedProjectId == null || userSpecifiedProjectId .isEmpty ();
489+ if (shouldFallbackToCredentials ) {
490+ Mockito .when (mockedGoogleCredentials .getProjectId ())
491+ .thenReturn (testCase .getCredentialsProjectId ());
492+ }
493+
494+ // prepare mock exporter
495+ OtlpGrpcSpanExporter mockOtlpGrpcSpanExporter = Mockito .mock (OtlpGrpcSpanExporter .class );
496+ OtlpGrpcSpanExporterBuilder spyOtlpGrpcSpanExporterBuilder =
497+ Mockito .spy (OtlpGrpcSpanExporter .builder ());
498+ List <SpanData > exportedSpans = new ArrayList <>();
499+ configureGrpcMockSpanExporter (
500+ mockOtlpGrpcSpanExporter , spyOtlpGrpcSpanExporterBuilder , exportedSpans );
501+
502+ try (MockedStatic <GoogleCredentials > googleCredentialsMockedStatic =
503+ Mockito .mockStatic (GoogleCredentials .class )) {
504+ googleCredentialsMockedStatic
505+ .when (GoogleCredentials ::getApplicationDefault )
506+ .thenReturn (mockedGoogleCredentials );
507+
508+ if (testCase .getExpectedToThrow ()) {
509+ // expect exception to be thrown when project ID is not available
510+ assertThatThrownBy (() -> buildOpenTelemetrySdkWithExporter (mockOtlpGrpcSpanExporter ))
511+ .isInstanceOf (ConfigurationException .class );
512+ // verify getProjectId() was called to attempt fallback
513+ Mockito .verify (mockedGoogleCredentials , Mockito .times (1 )).getProjectId ();
514+ } else {
515+ // export telemetry and verify resource attributes contain expected project ID
516+ OpenTelemetrySdk sdk = buildOpenTelemetrySdkWithExporter (mockOtlpGrpcSpanExporter );
517+ generateTestSpan (sdk );
518+ CompletableResultCode code = sdk .shutdown ();
519+ CompletableResultCode joinResult = code .join (10 , TimeUnit .SECONDS );
520+ assertThat (joinResult .isSuccess ()).isTrue ();
521+
522+ assertThat (exportedSpans ).hasSizeGreaterThan (0 );
523+ for (SpanData spanData : exportedSpans ) {
524+ assertThat (spanData .getResource ().getAttributes ().asMap ())
525+ .containsEntry (
526+ AttributeKey .stringKey (GCP_USER_PROJECT_ID_KEY ),
527+ testCase .getExpectedProjectIdInResource ());
528+ }
529+
530+ // verify whether getProjectId() was called based on whether fallback was needed
531+ if (shouldFallbackToCredentials ) {
532+ Mockito .verify (mockedGoogleCredentials , Mockito .times (1 )).getProjectId ();
533+ } else {
534+ Mockito .verify (mockedGoogleCredentials , Mockito .never ()).getProjectId ();
535+ }
536+ }
537+ }
538+ }
539+
459540 @ ParameterizedTest
460541 @ MethodSource ("provideTargetSignalBehaviorTestCases" )
461542 void testTargetSignalsBehavior (TargetSignalBehavior testCase ) {
@@ -665,6 +746,73 @@ private static Stream<Arguments> provideTargetSignalBehaviorTestCases() {
665746 .build ()));
666747 }
667748
749+ /**
750+ * Test cases specifying expected value for the project ID in the resource given the user input
751+ * and the current credentials state.
752+ *
753+ * <p>{@code null} for {@link ProjectIdTestBehavior#getUserSpecifiedProjectId()} indicates the
754+ * case of user not specifying the project ID.
755+ *
756+ * <p>{@code null} value for {@link ProjectIdTestBehavior#getCredentialsProjectId()} indicates
757+ * that the mocked credentials are not providing a project ID.
758+ *
759+ * <p>{@code true} for {@link ProjectIdTestBehavior#getExpectedToThrow()} indicates the
760+ * expectation that an exception should be thrown.
761+ */
762+ private static Stream <Arguments > provideProjectIdBehaviorTestCases () {
763+ return Stream .of (
764+ // User specified project ID takes precedence
765+ Arguments .of (
766+ ProjectIdTestBehavior .builder ()
767+ .setUserSpecifiedProjectId (DUMMY_GCP_RESOURCE_PROJECT_ID )
768+ .setCredentialsProjectId ("credentials-project-id" )
769+ .setExpectedProjectIdInResource (DUMMY_GCP_RESOURCE_PROJECT_ID )
770+ .setExpectedToThrow (false )
771+ .build ()),
772+ // If user specified project ID is empty, fallback to credentials.getProjectId()
773+ Arguments .of (
774+ ProjectIdTestBehavior .builder ()
775+ .setUserSpecifiedProjectId ("" )
776+ .setCredentialsProjectId ("credentials-project-id" )
777+ .setExpectedProjectIdInResource ("credentials-project-id" )
778+ .setExpectedToThrow (false )
779+ .build ()),
780+ // If user doesn't specify project ID, fallback to credentials.getProjectId()
781+ Arguments .of (
782+ ProjectIdTestBehavior .builder ()
783+ .setUserSpecifiedProjectId (null )
784+ .setCredentialsProjectId ("credentials-project-id" )
785+ .setExpectedProjectIdInResource ("credentials-project-id" )
786+ .setExpectedToThrow (false )
787+ .build ()),
788+ // If user doesn't specify and credentials.getProjectId() returns null, throw exception
789+ Arguments .of (
790+ ProjectIdTestBehavior .builder ()
791+ .setUserSpecifiedProjectId (null )
792+ .setCredentialsProjectId (null )
793+ .setExpectedProjectIdInResource (null )
794+ .setExpectedToThrow (true )
795+ .build ()),
796+ // If user specified project ID is empty and credentials.getProjectId() returns null, throw
797+ // exception
798+ Arguments .of (
799+ ProjectIdTestBehavior .builder ()
800+ .setUserSpecifiedProjectId ("" )
801+ .setCredentialsProjectId (null )
802+ .setExpectedProjectIdInResource (null )
803+ .setExpectedToThrow (true )
804+ .build ()),
805+ // If user specifies empty and credentials returns empty (edge case), resource has empty
806+ // project ID
807+ Arguments .of (
808+ ProjectIdTestBehavior .builder ()
809+ .setUserSpecifiedProjectId ("" )
810+ .setCredentialsProjectId ("" )
811+ .setExpectedProjectIdInResource ("" )
812+ .setExpectedToThrow (false )
813+ .build ()));
814+ }
815+
668816 /**
669817 * Test cases specifying expected value for the user quota project header given the user input and
670818 * the current credentials state.
@@ -839,6 +987,42 @@ private static void configureGrpcMockMetricExporter(
839987 .thenReturn (MemoryMode .IMMUTABLE_DATA );
840988 }
841989
990+ @ AutoValue
991+ abstract static class ProjectIdTestBehavior {
992+ // A null user specified project ID represents the use case where user omits specifying it
993+ @ Nullable
994+ abstract String getUserSpecifiedProjectId ();
995+
996+ // The project ID that credentials.getProjectId() returns (can be null)
997+ @ Nullable
998+ abstract String getCredentialsProjectId ();
999+
1000+ // The expected project ID in the resource attributes (null if exception expected)
1001+ @ Nullable
1002+ abstract String getExpectedProjectIdInResource ();
1003+
1004+ // Whether an exception is expected to be thrown
1005+ abstract boolean getExpectedToThrow ();
1006+
1007+ static Builder builder () {
1008+ return new AutoValue_GcpAuthAutoConfigurationCustomizerProviderTest_ProjectIdTestBehavior
1009+ .Builder ();
1010+ }
1011+
1012+ @ AutoValue .Builder
1013+ abstract static class Builder {
1014+ abstract Builder setUserSpecifiedProjectId (String projectId );
1015+
1016+ abstract Builder setCredentialsProjectId (String projectId );
1017+
1018+ abstract Builder setExpectedProjectIdInResource (String projectId );
1019+
1020+ abstract Builder setExpectedToThrow (boolean expectedToThrow );
1021+
1022+ abstract ProjectIdTestBehavior build ();
1023+ }
1024+ }
1025+
8421026 @ AutoValue
8431027 abstract static class QuotaProjectIdTestBehavior {
8441028 // A null user specified quota represents the use case where user omits specifying quota
@@ -847,7 +1031,8 @@ abstract static class QuotaProjectIdTestBehavior {
8471031
8481032 abstract boolean getIsQuotaProjectPresentInMetadata ();
8491033
850- // If expected quota project in header is null, the header entry should not be present in export
1034+ // If expected quota project in header is null, the header entry should not be present in the
1035+ // export
8511036 @ Nullable
8521037 abstract String getExpectedQuotaProjectInHeader ();
8531038
0 commit comments