Skip to content

Commit 281ca8c

Browse files
committed
improve service root derivation in CsrfTokenInterceptor
1 parent d63eef8 commit 281ca8c

2 files changed

Lines changed: 109 additions & 14 deletions

File tree

cloudplatform/connectivity-apache-httpclient5/src/main/java/com/sap/cloud/sdk/cloudplatform/connectivity/CsrfTokenInterceptor.java

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -90,25 +90,43 @@ class CsrfTokenInterceptor implements HttpRequestInterceptor
9090
}
9191

9292
/**
93-
* Derives the service root URI from the full request URI by truncating the path at the first OData resource
94-
* segment. This matches the HC4 behavior where the CSRF token HEAD request was always sent to the service path root
95-
* rather than the specific resource path.
93+
* Derives the service root URI from the full request URI to send the CSRF token HEAD request.
9694
* <p>
97-
* The service root is identified as the path up to and including the trailing slash before the first resource
98-
* segment. Example: {@code http://host/service/$batch} → {@code http://host/service/},
99-
* {@code http://host/service/Entity} → {@code http://host/service/}
95+
* The service root is the path prefix up to and including the slash that precedes the first OData resource segment.
96+
* A resource segment is identified by the presence of a key predicate ({@code (}) — the slash immediately before
97+
* the first {@code (} marks the boundary between the service path and the first entity set name. For paths without
98+
* a key predicate the last path segment is stripped instead.
99+
* <p>
100+
* Examples:
101+
* <ul>
102+
* <li>{@code /service/Entity} → {@code /service/}
103+
* <li>{@code /service/$batch} → {@code /service/}
104+
* <li>{@code /service/Entity('key')} → {@code /service/}
105+
* <li>{@code /service/Entity('key')/NavigationProperty} → {@code /service/}
106+
* <li>{@code /service/Entity('key')/NavigationProperty(42)} → {@code /service/}
107+
* </ul>
100108
*/
101109
@Nonnull
102110
static URI deriveServiceRootUri( @Nonnull final URI requestUri )
103111
{
104112
final String path = requestUri.getRawPath();
105-
// Service root is everything up to and including the trailing slash before the first resource segment.
106-
// Find the last '/' that is followed by at least one more character (i.e., there is a resource segment).
107-
final int lastSlash = path.lastIndexOf('/');
108-
// If the path ends with '/' already (e.g. "/service/"), use it as-is.
109-
// Otherwise, strip the last segment (e.g. "/service/Entity" -> "/service/", "/service/$batch" -> "/service/").
110-
final String servicePath =
111-
(lastSlash >= 0 && lastSlash < path.length() - 1) ? path.substring(0, lastSlash + 1) : path;
113+
final String servicePath;
114+
115+
final int firstParen = path.indexOf('(');
116+
if( firstParen > 0 ) {
117+
// A key predicate is present. The service root ends at the slash immediately before the first '(',
118+
// i.e. before the entity set name. This correctly handles navigation property paths such as
119+
// /service/Entity('key')/NavProp and /service/Entity('key')/NavProp(42).
120+
final int slashBeforeEntity = path.lastIndexOf('/', firstParen);
121+
servicePath = slashBeforeEntity >= 0 ? path.substring(0, slashBeforeEntity + 1) : path;
122+
} else {
123+
// No key predicate — strip the last path segment.
124+
// Handles /service/Entity -> /service/ and /service/$batch -> /service/.
125+
// Also handles paths that already end with '/' (e.g. /service/) by leaving them unchanged.
126+
final int lastSlash = path.lastIndexOf('/');
127+
servicePath = (lastSlash >= 0 && lastSlash < path.length() - 1) ? path.substring(0, lastSlash + 1) : path;
128+
}
129+
112130
try {
113131
return new URI(requestUri.getScheme(), requestUri.getAuthority(), servicePath, null, null);
114132
}

cloudplatform/connectivity-apache-httpclient5/src/test/java/com/sap/cloud/sdk/cloudplatform/connectivity/CsrfTokenInterceptorTest.java

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@
3838
import lombok.SneakyThrows;
3939

4040
@WireMockTest
41-
@SuppressWarnings( "unchecked" )
4241
class CsrfTokenInterceptorTest
4342
{
4443
private static final String CSRF_TOKEN = "test-csrf-token";
@@ -264,4 +263,82 @@ void deriveServiceRootUri_handlesDeeplyNestedPath()
264263
assertThat(CsrfTokenInterceptor.deriveServiceRootUri(URI.create("http://host/a/b/c/Entity")))
265264
.isEqualTo(URI.create("http://host/a/b/c/"));
266265
}
266+
267+
@Test
268+
void deriveServiceRootUri_stripsKeyPredicateWithStringKey()
269+
{
270+
assertThat(
271+
CsrfTokenInterceptor
272+
.deriveServiceRootUri(URI.create("http://host/sap/opu/odata/sap/API_BP/BusinessPartner('123')")))
273+
.isEqualTo(URI.create("http://host/sap/opu/odata/sap/API_BP/"));
274+
}
275+
276+
@Test
277+
void deriveServiceRootUri_stripsKeyPredicateWithIntegerKey()
278+
{
279+
assertThat(CsrfTokenInterceptor.deriveServiceRootUri(URI.create("http://host/service/Entity(42)")))
280+
.isEqualTo(URI.create("http://host/service/"));
281+
}
282+
283+
@Test
284+
void deriveServiceRootUri_stripsKeyPredicateWithCompoundKey()
285+
{
286+
assertThat(
287+
CsrfTokenInterceptor.deriveServiceRootUri(URI.create("http://host/service/Entity(key1='a',key2='b')")))
288+
.isEqualTo(URI.create("http://host/service/"));
289+
}
290+
291+
@Test
292+
void deriveServiceRootUri_stripsNavigationPropertyAfterKeyPredicate()
293+
{
294+
// /service/Entity('key')/NavProp — the nav property has no key itself
295+
assertThat(
296+
CsrfTokenInterceptor
297+
.deriveServiceRootUri(
298+
URI.create("http://host/sap/opu/odata/sap/API_BP/BusinessPartner('123')/to_Address")))
299+
.isEqualTo(URI.create("http://host/sap/opu/odata/sap/API_BP/"));
300+
}
301+
302+
@Test
303+
void deriveServiceRootUri_stripsNavigationPropertyWithItsOwnKeyPredicate()
304+
{
305+
// /service/Entity('key')/NavProp(42) — nav property has its own key
306+
assertThat(
307+
CsrfTokenInterceptor
308+
.deriveServiceRootUri(
309+
URI.create("http://host/sap/opu/odata/sap/API_BP/BusinessPartner('123')/to_Address(456)")))
310+
.isEqualTo(URI.create("http://host/sap/opu/odata/sap/API_BP/"));
311+
}
312+
313+
@Test
314+
@SneakyThrows
315+
void csrfTokenIsFetchedAtServiceRootForNavigationPropertyRequest( final WireMockRuntimeInfo wm )
316+
{
317+
// Verifies that for a mutating request on a navigation property path
318+
// /sap/opu/odata/sap/API_BP/BusinessPartner('123')/to_Address
319+
// the CSRF HEAD is sent to /sap/opu/odata/sap/API_BP/ (the service root),
320+
// NOT to /sap/opu/odata/sap/API_BP/BusinessPartner('123')/ (wrong intermediate path).
321+
final String serviceRoot = "/sap/opu/odata/sap/API_BP/";
322+
final String navPropertyPath = "/sap/opu/odata/sap/API_BP/BusinessPartner('123')/to_Address";
323+
324+
wm
325+
.getWireMock()
326+
.register(
327+
head(urlEqualTo(serviceRoot))
328+
.willReturn(ok().withHeader(CsrfTokenInterceptor.X_CSRF_TOKEN_HEADER_KEY, CSRF_TOKEN)));
329+
330+
final DefaultHttpDestination destination = DefaultHttpDestination.builder(wm.getHttpBaseUrl()).build();
331+
final HttpClient realClient = new ApacheHttpClient5FactoryBuilder().build().createHttpClient(destination);
332+
final CsrfTokenInterceptor interceptor = new CsrfTokenInterceptor(realClient);
333+
334+
final HttpPost request = new HttpPost(navPropertyPath);
335+
interceptor.process(request, null, null);
336+
337+
// Token must have been fetched and attached
338+
assertThat(request.getFirstHeader(CsrfTokenInterceptor.X_CSRF_TOKEN_HEADER_KEY).getValue())
339+
.isEqualTo(CSRF_TOKEN);
340+
341+
// HEAD must have gone to the service root, not to the intermediate entity path
342+
wm.getWireMock().verifyThat(headRequestedFor(urlEqualTo(serviceRoot)));
343+
}
267344
}

0 commit comments

Comments
 (0)