Skip to content

Commit d01958a

Browse files
chore: fixes multiple bugs (#338)
1 parent e885276 commit d01958a

10 files changed

Lines changed: 334 additions & 7 deletions

File tree

src/main/java/com/contentful/java/cda/CDAClient.java

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -495,15 +495,19 @@ public Map<String, CDAContentType> apply(Response<CDAArray> arrayResponse) {
495495
}
496496

497497
Flowable<CDAContentType> cacheTypeWithId(String id) {
498-
CDAContentType contentType = cache.types().get(id);
498+
Map<String, CDAContentType> types = cache.types();
499+
CDAContentType contentType = types == null ? null : types.get(id);
499500
if (contentType == null) {
500501
return observe(CDAContentType.class)
501502
.one(id)
502503
.map(new Function<CDAContentType, CDAContentType>() {
503504
@Override
504505
public CDAContentType apply(CDAContentType resource) {
505506
if (resource != null) {
506-
cache.types().put(resource.id(), resource);
507+
Map<String, CDAContentType> currentTypes = cache.types();
508+
if (currentTypes != null) {
509+
currentTypes.put(resource.id(), resource);
510+
}
507511
}
508512
return resource;
509513
}
@@ -580,7 +584,7 @@ public static class Builder {
580584

581585
boolean preview;
582586

583-
private boolean logSensitiveData = true;
587+
private boolean logSensitiveData = false;
584588
Tls12Implementation tls12Implementation = useRecommendation;
585589

586590
Section application;

src/main/java/com/contentful/java/cda/CDAHttpException.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ public CDAHttpException(Request request, Response response, boolean logSensitive
4242
}
4343

4444
private String readResponseBody(Response response) {
45+
if (response.body() == null) {
46+
return "<no response body>";
47+
}
4548
try {
4649
BufferedSource bufferedSource = response.body().source();
4750
Timeout timeout = bufferedSource.timeout();

src/main/java/com/contentful/java/cda/Cache.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ List<CDALocale> locales() {
1919
}
2020

2121
protected CDALocale defaultLocale() {
22-
return defaultLocale;
22+
synchronized (localesLock) {
23+
return defaultLocale;
24+
}
2325
}
2426

2527
void setLocales(List<CDALocale> locales) {
@@ -35,6 +37,7 @@ void updateDefaultLocale() {
3537
for (final CDALocale locale : this.locales) {
3638
if (locale.isDefaultLocale()) {
3739
this.defaultLocale = locale;
40+
break;
3841
}
3942
}
4043
}

src/main/java/com/contentful/java/cda/ObserveQuery.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
import org.reactivestreams.Publisher;
66
import retrofit2.Response;
77

8+
import java.util.Map;
9+
810
import static com.contentful.java.cda.CDAType.LOCALE;
911
import static com.contentful.java.cda.CDAType.TAG;
1012
import static com.contentful.java.cda.CDAType.ASSET;
@@ -67,7 +69,10 @@ public Flowable<T> one(final String id) {
6769
if (CONTENTTYPE.equals(typeForClass(type))) {
6870
flowable = flowable.map(t -> {
6971
if (t != null) {
70-
client.cache.types().put(t.id(), (CDAContentType) t);
72+
Map<String, CDAContentType> types = client.cache.types();
73+
if (types != null) {
74+
types.put(t.id(), (CDAContentType) t);
75+
}
7176
}
7277
return t;
7378
});

src/main/java/com/contentful/java/cda/rich/RichTextFactory.java

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,9 @@ private static void resolveRichLink(ArrayResource array, CDAEntry entry, CDAFiel
178178

179179
for (final String locale : rawValue.keySet()) {
180180
final CDARichDocument document = entry.getField(locale, field.id());
181+
if (document == null) {
182+
continue;
183+
}
181184
for (final CDARichNode node : document.getContent()) {
182185
resolveOneLink(array, field, locale, node);
183186
}
@@ -276,8 +279,8 @@ private static boolean isLink(Object data) {
276279
final String id = (String) sys.get("id");
277280

278281
if ("Link".equals(type)
279-
&& ("Entry".equals(linkType) || "Asset".equals(linkType)
280-
&& id != null)) {
282+
&& ("Entry".equals(linkType) || "Asset".equals(linkType))
283+
&& id != null) {
281284
return true;
282285
}
283286
} catch (ClassCastException cast) {

src/test/java/com/contentful/java/cda/ClientTest.java

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,19 @@
1010
import org.junit.Test;
1111

1212
import java.io.IOException;
13+
import java.util.Map;
1314
import java.util.concurrent.CountDownLatch;
15+
import java.util.concurrent.ExecutorService;
16+
import java.util.concurrent.Executors;
17+
import java.util.concurrent.TimeUnit;
18+
import java.util.concurrent.atomic.AtomicReference;
1419

1520
import okhttp3.Call;
1621
import okhttp3.Headers;
1722
import okhttp3.Interceptor;
1823
import okhttp3.OkHttpClient;
1924
import okhttp3.Response;
25+
import okhttp3.mockwebserver.MockResponse;
2026
import okhttp3.mockwebserver.RecordedRequest;
2127

2228
import static com.contentful.java.cda.SyncType.onlyEntriesOfType;
@@ -353,6 +359,40 @@ public void requestingWhileRateLimitedThrows() {
353359
}
354360
}
355361

362+
// Regression test: response.body() can be null for a body-less unsuccessful response (e.g. an
363+
// upstream proxy returning a bare 500/502 with no content). readResponseBody() in
364+
// CDAHttpException used to dereference response.body() without a null check, throwing an
365+
// unrelated NullPointerException that masked the real HTTP error. HTTP 204/304 are treated as
366+
// successful by Retrofit/OkHttp and never reach CDAHttpException, so this is reproduced via a
367+
// body-less 5xx error response instead.
368+
@Test
369+
@Enqueue
370+
public void bodyLess500ResponseDoesNotThrowNpeAndProducesMeaningfulException() {
371+
server.enqueue(new MockResponse().setResponseCode(500));
372+
373+
try {
374+
client.fetch(CDAEntry.class).all();
375+
throw new AssertionError("Expected CDAHttpException to be thrown");
376+
} catch (CDAHttpException cdaException) {
377+
assertThat(cdaException.responseCode()).isEqualTo(500);
378+
assertThat(cdaException.responseBody()).isNotNull();
379+
}
380+
}
381+
382+
@Test
383+
@Enqueue
384+
public void bodyLess502ResponseDoesNotThrowNpeAndProducesMeaningfulException() {
385+
server.enqueue(new MockResponse().setResponseCode(502));
386+
387+
try {
388+
client.fetch(CDAEntry.class).all();
389+
throw new AssertionError("Expected CDAHttpException to be thrown");
390+
} catch (CDAHttpException cdaException) {
391+
assertThat(cdaException.responseCode()).isEqualTo(502);
392+
assertThat(cdaException.responseBody()).isNotNull();
393+
}
394+
}
395+
356396
@Test(expected = IllegalArgumentException.class)
357397
@Enqueue("demo/content_types_cat.json")
358398
public void settingNoLoggerAndAnyLogLevelResultsException() {
@@ -381,6 +421,87 @@ public void clearingTheCacheClearsTheCache() {
381421
assertThat(client.cache.locales()).isNull();
382422
}
383423

424+
// Regression test: cache.types() returns null right after clearCache() is called.
425+
// CDAClient#cacheTypeWithId(String) and the content-type caching step in
426+
// ObserveQuery#one(String) used to dereference that null directly, causing an unhandled
427+
// NullPointerException.
428+
@Test
429+
@Enqueue(defaults = {}, value = {
430+
"demo/locales.json",
431+
"demo/content_types_cat.json",
432+
"demo/content_types_cat.json",
433+
"demo/locales.json",
434+
"demo/content_types_cat.json",
435+
"demo/content_types_cat.json"
436+
})
437+
public void cacheTypeWithIdAfterClearCacheDoesNotThrow() {
438+
// populate the cache first
439+
client.fetch(CDAContentType.class).all();
440+
assertThat(client.cache.types()).isNotNull();
441+
442+
client.clearCache();
443+
// Preserve the existing, tested contract: cache.types() is null right after clear().
444+
assertThat(client.cache.types()).isNull();
445+
446+
CDAContentType result = client.cacheTypeWithId("cat").blockingFirst();
447+
448+
assertThat(result).isNotNull();
449+
assertThat(result.id()).isEqualTo("cat");
450+
}
451+
452+
// Regression test: concurrent Cache#clear() and Cache#types() reads must never throw, since
453+
// clearCache() is a public API that can legitimately race with any in-flight observable chain.
454+
@Test
455+
public void concurrentClearAndReadOfCacheTypesDoesNotThrow() throws InterruptedException {
456+
final Cache cache = new Cache();
457+
final int iterations = 500;
458+
final CountDownLatch start = new CountDownLatch(1);
459+
final AtomicReference<Throwable> failure = new AtomicReference<>();
460+
ExecutorService executor = Executors.newFixedThreadPool(2);
461+
462+
Runnable clearer = () -> {
463+
try {
464+
start.await();
465+
for (int i = 0; i < iterations; i++) {
466+
cache.clear();
467+
}
468+
} catch (Throwable t) {
469+
failure.compareAndSet(null, t);
470+
}
471+
};
472+
473+
Runnable reader = () -> {
474+
try {
475+
start.await();
476+
for (int i = 0; i < iterations; i++) {
477+
Map<String, CDAContentType> types = cache.types();
478+
if (types != null) {
479+
types.get("any-id");
480+
}
481+
}
482+
} catch (Throwable t) {
483+
failure.compareAndSet(null, t);
484+
}
485+
};
486+
487+
executor.submit(clearer);
488+
executor.submit(reader);
489+
start.countDown();
490+
491+
executor.shutdown();
492+
executor.awaitTermination(5, TimeUnit.SECONDS);
493+
494+
assertThat(failure.get()).isNull();
495+
}
496+
497+
// Regression test: the builder used to default logSensitiveData to `true`, leaking
498+
// authorization headers into CDAHttpException#toString() output by default.
499+
@Test
500+
public void logSensitiveDataDefaultsToFalse() {
501+
CDAClient defaultClient = createBuilder().build();
502+
assertThat(defaultClient.shouldLogSensitiveData()).isFalse();
503+
}
504+
384505
@Test
385506
@Enqueue("demo/content_types_cat.json")
386507
public void localesGetCached() {

src/test/java/com/contentful/java/cda/RichTextTest.java

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,31 @@
2424
import static com.google.common.truth.Truth.assertThat;
2525

2626
public class RichTextTest extends BaseTest {
27+
28+
// Regression test: RichTextFactory.resolveRichLink() used to call document.getContent() on
29+
// the result of entry.getField(locale, field.id()) without checking for null. That method
30+
// returns null whenever a rich text field is present in the content model but not populated
31+
// (and has no locale fallback configured) for a given locale - a common situation in partially
32+
// localised spaces - causing an unhandled NullPointerException that failed the entire fetch,
33+
// even for locales where the field was populated correctly.
34+
@Test
35+
@Enqueue(
36+
value = "rich_text/entry_missing_locale.json",
37+
defaults = {"rich_text/locales_two.json", "rich_text/content_types.json"}
38+
)
39+
public void richTextFieldMissingForOneLocaleDoesNotThrowAndOtherLocaleResolvesFine() {
40+
final CDAEntry entry = (CDAEntry) client.fetch(CDAEntry.class).all().items().get(0);
41+
42+
final CDARichDocument populatedLocale = entry.getField("en-US", "rich");
43+
assertThat(populatedLocale).isNotNull();
44+
assertThat(populatedLocale.getContent()).isNotEmpty();
45+
46+
// de-DE has no fallback locale configured and the raw field value was explicitly null -
47+
// it must resolve to a safely-skipped/unresolved value, not throw.
48+
final CDARichDocument unpopulatedLocale = entry.getField("de-DE", "rich");
49+
assertThat(unpopulatedLocale).isNull();
50+
}
51+
2752
@Test
2853
@Enqueue(value = "rich_text/simple_headline_1.json", defaults = {"rich_text/locales.json", "rich_text/content_types.json"})
2954
public void simple_headline_1_test() {
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package com.contentful.java.cda.rich;
2+
3+
import org.junit.Test;
4+
5+
import java.lang.reflect.Method;
6+
import java.util.HashMap;
7+
import java.util.Map;
8+
9+
import static com.google.common.truth.Truth.assertThat;
10+
11+
/**
12+
* Regression tests for {@link RichTextFactory}'s private {@code isLink(Object)} method.
13+
* <p>
14+
* A prior operator-precedence defect (missing parentheses around a {@code ||} sub-expression)
15+
* meant {@code id != null} only guarded the "Asset" branch, so an "Entry" link with a null id
16+
* incorrectly returned {@code true}, later causing {@code link.data} to be silently set to
17+
* {@code null} and crashing any renderer that dereferenced it as a {@code CDAEntry}.
18+
* <p>
19+
* Uses reflection to reach the private method directly, since it is not otherwise exposed.
20+
*/
21+
public class RichTextFactoryIsLinkTest {
22+
23+
private boolean invokeIsLink(Map<String, Object> data) throws Exception {
24+
Method method = RichTextFactory.class.getDeclaredMethod("isLink", Object.class);
25+
method.setAccessible(true);
26+
return (boolean) method.invoke(null, data);
27+
}
28+
29+
private Map<String, Object> link(String linkType, String id) {
30+
Map<String, Object> sys = new HashMap<>();
31+
sys.put("type", "Link");
32+
sys.put("linkType", linkType);
33+
if (id != null) {
34+
sys.put("id", id);
35+
}
36+
Map<String, Object> data = new HashMap<>();
37+
data.put("sys", sys);
38+
return data;
39+
}
40+
41+
@Test
42+
public void entryLinkWithNullIdIsRejected() throws Exception {
43+
assertThat(invokeIsLink(link("Entry", null))).isFalse();
44+
}
45+
46+
@Test
47+
public void assetLinkWithNullIdIsRejected() throws Exception {
48+
assertThat(invokeIsLink(link("Asset", null))).isFalse();
49+
}
50+
51+
@Test
52+
public void entryLinkWithValidIdIsAccepted() throws Exception {
53+
assertThat(invokeIsLink(link("Entry", "someEntryId"))).isTrue();
54+
}
55+
56+
@Test
57+
public void assetLinkWithValidIdIsAccepted() throws Exception {
58+
assertThat(invokeIsLink(link("Asset", "someAssetId"))).isTrue();
59+
}
60+
61+
@Test
62+
public void unknownLinkTypeIsRejected() throws Exception {
63+
assertThat(invokeIsLink(link("Space", "someId"))).isFalse();
64+
}
65+
}

0 commit comments

Comments
 (0)