77import static org .assertj .core .api .Assertions .assertThat ;
88import static org .junit .jupiter .api .Assertions .assertThrows ;
99
10- import com .salesforce .datacloud .jdbc .auth .model .DataCloudTokenResponse ;
11- import java .lang .reflect .Field ;
10+ import com .fasterxml .jackson .databind .ObjectMapper ;
11+ import com .fasterxml .jackson .databind .node .ObjectNode ;
12+ import java .nio .charset .StandardCharsets ;
1213import java .sql .SQLException ;
14+ import java .time .Instant ;
15+ import java .util .Base64 ;
1316import java .util .Properties ;
1417import java .util .UUID ;
1518import org .junit .jupiter .api .Test ;
1619
1720class DirectCdpTokenProcessorTest {
1821
19- private static final String TENANT_URL = "https://test.c360a.salesforce.com" ;
20- static final String FAKE_TOKEN =
21- "eyJraWQiOiJDT1JFLjAwRE9LMDAwMDAwOVp6ci4xNzE4MDUyMTU0NDIyIiwidHlwIjoiSldUIiwiYWxnIjoiRVMyNTYifQ.eyJzdWIiOiJodHRwczovL2xvZ2luLnRlc3QxLnBjLXJuZC5zYWxlc2ZvcmNlLmNvbS9pZC8wMERPSzAwMDAwMDlaenIyQUUvMDA1T0swMDAwMDBVeTkxWUFDIiwic2NwIjoiY2RwX3Byb2ZpbGVfYXBpIGNkcF9pbmdlc3RfYXBpIGNkcF9pZGVudGl0eXJlc29sdXRpb25fYXBpIGNkcF9zZWdtZW50X2FwaSBjZHBfcXVlcnlfYXBpIGNkcF9hcGkiLCJpc3MiOiJodHRwczovL2xvZ2luLnRlc3QxLnBjLXJuZC5zYWxlc2ZvcmNlLmNvbS8iLCJvcmdJZCI6IjAwRE9LMDAwMDAwOVp6ciIsImlzc3VlclRlbmFudElkIjoiY29yZS9mYWxjb250ZXN0MS1jb3JlNG9yYTE1LzAwRE9LMDAwMDAwOVp6cjJBRSIsInNmYXBwaWQiOiIzTVZHOVhOVDlUbEI3VmtZY0tIVm5sUUZzWEd6cUJuMGszUC5zNHJBU0I5V09oRU1OdkgyNzNpM1NFRzF2bWl3WF9YY2NXOUFZbHA3VnJnQ3BGb0ZXIiwiYXVkaWVuY2VUZW5hbnRJZCI6ImEzNjAvZmFsY29uZGV2L2E2ZDcyNmE3M2Y1MzQzMjdhNmE4ZTJlMGYzY2MzODQwIiwiY3VzdG9tX2F0dHJpYnV0ZXMiOnsiZGF0YXNwYWNlIjoiZGVmYXVsdCJ9LCJhdWQiOiJhcGkuYTM2MC5zYWxlc2ZvcmNlLmNvbSIsIm5iZiI6MTcyMDczMTAyMSwic2ZvaWQiOiIwMERPSzAwMDAwMDlaenIiLCJzZnVpZCI6IjAwNU9LMDAwMDAwVXk5MSIsImV4cCI6MTcyMDczODI4MCwiaWF0IjoxNzIwNzMxMDgxLCJqdGkiOiIwYjYwMzc4OS1jMGI2LTQwZTMtYmIzNi03NDQ3MzA2MzAxMzEifQ.lXgeAhJIiGoxgNpBi0W5oBWyn2_auB2bFxxajGuK6DMHlkqDhHJAlFN_uf6QPSjGSJCh5j42Ow5SrEptUDJwmQ" ;
22- static final String FAKE_TENANT_ID = "a360/falcondev/a6d726a73f534327a6a8e2e0f3cc3840" ;
22+ private static final String TENANT_HOST = "test.c360a.salesforce.com" ;
23+ private static final String TENANT_ID = "a360/falcondev/a6d726a73f534327a6a8e2e0f3cc3840" ;
24+
25+ /**
26+ * Builds an unsigned JWT with the given audienceTenantId and exp claim. The {@link DataCloudToken}
27+ * decoder only base64-parses the payload — it does not verify the signature — so a fixed bogus
28+ * signature is enough to exercise the production path.
29+ */
30+ private static String jwtWithExp (long expEpochSeconds ) {
31+ try {
32+ ObjectNode header = new ObjectMapper ().createObjectNode ();
33+ header .put ("alg" , "ES256" );
34+ header .put ("typ" , "JWT" );
35+
36+ ObjectNode payload = new ObjectMapper ().createObjectNode ();
37+ payload .put ("audienceTenantId" , TENANT_ID );
38+ payload .put ("exp" , expEpochSeconds );
39+
40+ Base64 .Encoder enc = Base64 .getUrlEncoder ().withoutPadding ();
41+ String h = enc .encodeToString (header .toString ().getBytes (StandardCharsets .UTF_8 ));
42+ String p = enc .encodeToString (payload .toString ().getBytes (StandardCharsets .UTF_8 ));
43+ return h + "." + p + ".sig" ;
44+ } catch (Exception e ) {
45+ throw new RuntimeException (e );
46+ }
47+ }
48+
49+ private static String validJwt () {
50+ return jwtWithExp (Instant .now ().getEpochSecond () + 3600 );
51+ }
2352
2453 private static Properties propertiesForCdpToken (String cdpToken , String tenantUrl ) {
2554 Properties properties = new Properties ();
@@ -30,51 +59,69 @@ private static Properties propertiesForCdpToken(String cdpToken, String tenantUr
3059
3160 @ Test
3261 void getDataCloudTokenReturnsValidToken () throws SQLException {
62+ String token = validJwt ();
3363 DirectCdpTokenProcessor processor =
34- DirectCdpTokenProcessor .ofDestructive (propertiesForCdpToken (FAKE_TOKEN , TENANT_URL ));
35- DataCloudToken token = processor .getDataCloudToken ();
64+ DirectCdpTokenProcessor .ofDestructive (propertiesForCdpToken (token , TENANT_HOST ));
65+ DataCloudToken dcToken = processor .getDataCloudToken ();
66+
67+ assertThat (dcToken .getAccessToken ()).isEqualTo ("Bearer " + token );
68+ assertThat (dcToken .getTenantUrl ()).isEqualTo (TENANT_HOST );
69+ assertThat (dcToken .getTenantId ()).isEqualTo (TENANT_ID );
70+ assertThat (dcToken .isAlive ()).isTrue ();
71+ }
3672
37- assertThat (token .getAccessToken ()).isEqualTo ("Bearer " + FAKE_TOKEN );
38- assertThat (token .getTenantUrl ()).isEqualTo (TENANT_URL );
39- assertThat (token .getTenantId ()).isEqualTo (FAKE_TENANT_ID );
40- assertThat (token .isAlive ()).isTrue ();
73+ @ Test
74+ void ofDestructiveRejectsTenantUrlWithScheme () {
75+ for (String invalid : new String [] {
76+ "https://" + TENANT_HOST , "http://" + TENANT_HOST , TENANT_HOST + ":443" , TENANT_HOST + "/" ,
77+ }) {
78+ Properties props = propertiesForCdpToken (validJwt (), invalid );
79+ SQLException ex = assertThrows (
80+ SQLException .class ,
81+ () -> DirectCdpTokenProcessor .ofDestructive (props ),
82+ "Expected rejection of: " + invalid );
83+ assertThat (ex .getMessage ()).contains ("bare hostname" );
84+ }
85+ }
86+
87+ @ Test
88+ void ofDestructiveRejectsTenantUrlWithWhitespace () {
89+ Properties props = propertiesForCdpToken (validJwt (), " " + TENANT_HOST + " " );
90+ SQLException ex = assertThrows (SQLException .class , () -> DirectCdpTokenProcessor .ofDestructive (props ));
91+ assertThat (ex .getMessage ()).contains ("whitespace" );
4192 }
4293
4394 @ Test
4495 void getDataCloudTokenReturnsCachedToken () throws SQLException {
4596 DirectCdpTokenProcessor processor =
46- DirectCdpTokenProcessor .ofDestructive (propertiesForCdpToken (FAKE_TOKEN , TENANT_URL ));
97+ DirectCdpTokenProcessor .ofDestructive (propertiesForCdpToken (validJwt (), TENANT_HOST ));
4798 DataCloudToken first = processor .getDataCloudToken ();
4899 DataCloudToken second = processor .getDataCloudToken ();
49100
50- assertThat (first . getAccessToken ()). isEqualTo (second . getAccessToken () );
101+ assertThat (first ). isSameAs (second );
51102 }
52103
53104 @ Test
54105 void getLakehouseWithoutDataspace () throws SQLException {
55106 DirectCdpTokenProcessor processor =
56- DirectCdpTokenProcessor .ofDestructive (propertiesForCdpToken (FAKE_TOKEN , TENANT_URL ));
57- String lakehouse = processor .getLakehouse ();
58-
59- assertThat (lakehouse ).isEqualTo ("lakehouse:" + FAKE_TENANT_ID + ";" );
107+ DirectCdpTokenProcessor .ofDestructive (propertiesForCdpToken (validJwt (), TENANT_HOST ));
108+ assertThat (processor .getLakehouse ()).isEqualTo ("lakehouse:" + TENANT_ID + ";" );
60109 }
61110
62111 @ Test
63112 void getLakehouseWithDataspace () throws SQLException {
64113 String dataspace = UUID .randomUUID ().toString ();
65- Properties props = propertiesForCdpToken (FAKE_TOKEN , TENANT_URL );
114+ Properties props = propertiesForCdpToken (validJwt (), TENANT_HOST );
66115 props .setProperty ("dataspace" , dataspace );
67116
68117 DirectCdpTokenProcessor processor = DirectCdpTokenProcessor .ofDestructive (props );
69- String lakehouse = processor .getLakehouse ();
70-
71- assertThat (lakehouse ).isEqualTo ("lakehouse:" + FAKE_TENANT_ID + ";" + dataspace );
118+ assertThat (processor .getLakehouse ()).isEqualTo ("lakehouse:" + TENANT_ID + ";" + dataspace );
72119 }
73120
74121 @ Test
75122 void ofDestructiveThrowsWhenCdpTokenMissing () {
76123 Properties props = new Properties ();
77- props .setProperty ("tenantUrl" , TENANT_URL );
124+ props .setProperty ("tenantUrl" , TENANT_HOST );
78125
79126 SQLException ex = assertThrows (SQLException .class , () -> DirectCdpTokenProcessor .ofDestructive (props ));
80127 assertThat (ex .getMessage ()).contains ("cdpToken" );
@@ -83,23 +130,23 @@ void ofDestructiveThrowsWhenCdpTokenMissing() {
83130 @ Test
84131 void ofDestructiveThrowsWhenTenantUrlMissing () {
85132 Properties props = new Properties ();
86- props .setProperty ("cdpToken" , FAKE_TOKEN );
133+ props .setProperty ("cdpToken" , validJwt () );
87134
88135 SQLException ex = assertThrows (SQLException .class , () -> DirectCdpTokenProcessor .ofDestructive (props ));
89136 assertThat (ex .getMessage ()).contains ("tenantUrl" );
90137 }
91138
92139 @ Test
93140 void ofDestructiveThrowsWhenCdpTokenIsInvalidJwt () {
94- Properties props = propertiesForCdpToken ("not-a-valid-jwt" , TENANT_URL );
141+ Properties props = propertiesForCdpToken ("not-a-valid-jwt" , TENANT_HOST );
95142
96143 SQLException ex = assertThrows (SQLException .class , () -> DirectCdpTokenProcessor .ofDestructive (props ));
97144 assertThat (ex .getMessage ()).contains ("Invalid CDP token" );
98145 }
99146
100147 @ Test
101148 void ofDestructiveRemovesPropertiesFromInput () throws SQLException {
102- Properties props = propertiesForCdpToken (FAKE_TOKEN , TENANT_URL );
149+ Properties props = propertiesForCdpToken (validJwt (), TENANT_HOST );
103150 props .setProperty ("dataspace" , "myspace" );
104151
105152 DirectCdpTokenProcessor .ofDestructive (props );
@@ -111,75 +158,50 @@ void ofDestructiveRemovesPropertiesFromInput() throws SQLException {
111158
112159 @ Test
113160 void hasCdpTokenReturnsTrueWhenBothPresent () {
114- Properties props = propertiesForCdpToken (FAKE_TOKEN , TENANT_URL );
161+ Properties props = propertiesForCdpToken (validJwt (), TENANT_HOST );
115162 assertThat (DirectCdpTokenProcessor .hasCdpToken (props )).isTrue ();
116163 }
117164
118165 @ Test
119- void getDataCloudTokenRebuildsWhenCachedTokenExpired () throws Exception {
120- DirectCdpTokenProcessor processor =
121- DirectCdpTokenProcessor .ofDestructive (propertiesForCdpToken (FAKE_TOKEN , TENANT_URL ));
122-
123- DataCloudTokenResponse expiredResponse = new DataCloudTokenResponse ();
124- expiredResponse .setToken (FAKE_TOKEN );
125- expiredResponse .setInstanceUrl (TENANT_URL );
126- expiredResponse .setTokenType ("Bearer" );
127- expiredResponse .setExpiresIn (-1 );
128- DataCloudToken expired = DataCloudToken .of (expiredResponse );
129- assertThat (expired .isAlive ()).isFalse ();
166+ void hasCdpTokenReturnsFalseWhenMissing () {
167+ assertThat (DirectCdpTokenProcessor .hasCdpToken (new Properties ())).isFalse ();
168+ assertThat (DirectCdpTokenProcessor .hasCdpToken (null )).isFalse ();
130169
131- Field cache = DirectCdpTokenProcessor . class . getDeclaredField ( "cachedDataCloudToken" );
132- cache . setAccessible ( true );
133- cache . set ( processor , expired );
170+ Properties onlyCdpToken = new Properties ( );
171+ onlyCdpToken . setProperty ( "cdpToken" , validJwt () );
172+ assertThat ( DirectCdpTokenProcessor . hasCdpToken ( onlyCdpToken )). isFalse ( );
134173
135- DataCloudToken rebuilt = processor . getDataCloudToken ();
136- assertThat ( rebuilt ). isNotSameAs ( expired );
137- assertThat (rebuilt . isAlive ( )).isTrue ();
174+ Properties onlyTenantUrl = new Properties ();
175+ onlyTenantUrl . setProperty ( "tenantUrl" , TENANT_HOST );
176+ assertThat (DirectCdpTokenProcessor . hasCdpToken ( onlyTenantUrl )).isFalse ();
138177 }
139178
140179 @ Test
141- void getDataCloudTokenRebuildsAfterCacheCleared () throws Exception {
142- DirectCdpTokenProcessor processor =
143- DirectCdpTokenProcessor .ofDestructive (propertiesForCdpToken (FAKE_TOKEN , TENANT_URL ));
144-
145- Field cache = DirectCdpTokenProcessor .class .getDeclaredField ("cachedDataCloudToken" );
146- cache .setAccessible (true );
147- cache .set (processor , null );
148-
149- DataCloudToken rebuilt = processor .getDataCloudToken ();
150- assertThat (rebuilt .getAccessToken ()).isEqualTo ("Bearer " + FAKE_TOKEN );
151- assertThat (rebuilt .getTenantId ()).isEqualTo (FAKE_TENANT_ID );
180+ void secondsUntilJwtExpiryReturnsRemainingForValidJwt () {
181+ long futureExp = Instant .now ().getEpochSecond () + 1234 ;
182+ int remaining = DirectCdpTokenProcessor .secondsUntilJwtExpiry (jwtWithExp (futureExp ));
183+ assertThat (remaining ).isBetween (1230 , 1234 );
152184 }
153185
154186 @ Test
155- void getDataCloudTokenWrapsRebuildFailure () throws Exception {
156- DirectCdpTokenProcessor processor =
157- DirectCdpTokenProcessor .ofDestructive (propertiesForCdpToken (FAKE_TOKEN , TENANT_URL ));
158-
159- Field cache = DirectCdpTokenProcessor .class .getDeclaredField ("cachedDataCloudToken" );
160- cache .setAccessible (true );
161- cache .set (processor , null );
162-
163- Field tenantUrl = DirectCdpTokenProcessor .class .getDeclaredField ("tenantUrl" );
164- tenantUrl .setAccessible (true );
165- tenantUrl .set (processor , null );
166-
167- SQLException ex = assertThrows (SQLException .class , processor ::getDataCloudToken );
168- assertThat (ex .getSQLState ()).isEqualTo ("28000" );
169- assertThat (cache .get (processor )).isNull ();
187+ void secondsUntilJwtExpiryFallsBackWhenJwtSingleSegment () {
188+ assertThat (DirectCdpTokenProcessor .secondsUntilJwtExpiry ("only-one-segment" ))
189+ .isEqualTo (DirectCdpTokenProcessor .FALLBACK_EXPIRES_IN_SECONDS );
170190 }
171191
172192 @ Test
173- void hasCdpTokenReturnsFalseWhenMissing () {
174- assertThat (DirectCdpTokenProcessor .hasCdpToken (new Properties ())).isFalse ();
175- assertThat (DirectCdpTokenProcessor .hasCdpToken (null )).isFalse ();
176-
177- Properties onlyCdpToken = new Properties ();
178- onlyCdpToken .setProperty ("cdpToken" , FAKE_TOKEN );
179- assertThat (DirectCdpTokenProcessor .hasCdpToken (onlyCdpToken )).isFalse ();
193+ void secondsUntilJwtExpiryFallsBackWhenPayloadNotBase64 () {
194+ // Two segments but second one is not valid base64url
195+ assertThat (DirectCdpTokenProcessor .secondsUntilJwtExpiry ("header.@@not-base64@@.sig" ))
196+ .isEqualTo (DirectCdpTokenProcessor .FALLBACK_EXPIRES_IN_SECONDS );
197+ }
180198
181- Properties onlyTenantUrl = new Properties ();
182- onlyTenantUrl .setProperty ("tenantUrl" , TENANT_URL );
183- assertThat (DirectCdpTokenProcessor .hasCdpToken (onlyTenantUrl )).isFalse ();
199+ @ Test
200+ void secondsUntilJwtExpiryFallsBackWhenExpClaimMissing () {
201+ Base64 .Encoder enc = Base64 .getUrlEncoder ().withoutPadding ();
202+ String header = enc .encodeToString ("{\" typ\" :\" JWT\" }" .getBytes (StandardCharsets .UTF_8 ));
203+ String payload = enc .encodeToString ("{\" sub\" :\" x\" }" .getBytes (StandardCharsets .UTF_8 ));
204+ assertThat (DirectCdpTokenProcessor .secondsUntilJwtExpiry (header + "." + payload + ".sig" ))
205+ .isEqualTo (DirectCdpTokenProcessor .FALLBACK_EXPIRES_IN_SECONDS );
184206 }
185207}
0 commit comments