diff --git a/config/checkstyle/checkstyle-suppressions.xml b/config/checkstyle/checkstyle-suppressions.xml index 31b632fc..2355e17a 100644 --- a/config/checkstyle/checkstyle-suppressions.xml +++ b/config/checkstyle/checkstyle-suppressions.xml @@ -15,6 +15,7 @@ + diff --git a/src/main/java/dev/sorn/fmp4j/cfg/FmpConfig.java b/src/main/java/dev/sorn/fmp4j/cfg/FmpConfig.java index 21ab6bcb..2ca32408 100644 --- a/src/main/java/dev/sorn/fmp4j/cfg/FmpConfig.java +++ b/src/main/java/dev/sorn/fmp4j/cfg/FmpConfig.java @@ -1,7 +1,9 @@ package dev.sorn.fmp4j.cfg; +import dev.sorn.fmp4j.types.FmpApiKey; + public interface FmpConfig { - String fmpApiKey(); + FmpApiKey fmpApiKey(); String fmpBaseUrl(); } diff --git a/src/main/java/dev/sorn/fmp4j/cfg/FmpConfigImpl.java b/src/main/java/dev/sorn/fmp4j/cfg/FmpConfigImpl.java index e1393e3c..5f3704ff 100644 --- a/src/main/java/dev/sorn/fmp4j/cfg/FmpConfigImpl.java +++ b/src/main/java/dev/sorn/fmp4j/cfg/FmpConfigImpl.java @@ -3,15 +3,17 @@ import static java.lang.System.getProperty; import static java.lang.System.getenv; +import dev.sorn.fmp4j.types.FmpApiKey; + public final class FmpConfigImpl implements FmpConfig { public static final String FMP4J_API_KEY_ENV = "FMP_API_KEY"; public static final String FMP4J_BASE_URL_ENV = "FMP_BASE_URL"; - private final String fmpApiKey; + private final FmpApiKey fmpApiKey; private final String fmpBaseUrl; public FmpConfigImpl() { - fmpApiKey = getRequiredEnv(FMP4J_API_KEY_ENV); + fmpApiKey = new FmpApiKey(getRequiredEnv(FMP4J_API_KEY_ENV)); fmpBaseUrl = getRequiredEnv(FMP4J_BASE_URL_ENV); } @@ -27,7 +29,7 @@ private String getRequiredEnv(String name) { } @Override - public String fmpApiKey() { + public FmpApiKey fmpApiKey() { return fmpApiKey; } diff --git a/src/main/java/dev/sorn/fmp4j/exceptions/FmpInvalidApiKey.java b/src/main/java/dev/sorn/fmp4j/exceptions/FmpInvalidApiKey.java new file mode 100644 index 00000000..141443ea --- /dev/null +++ b/src/main/java/dev/sorn/fmp4j/exceptions/FmpInvalidApiKey.java @@ -0,0 +1,7 @@ +package dev.sorn.fmp4j.exceptions; + +public class FmpInvalidApiKey extends FmpException { + public FmpInvalidApiKey(String message, Object... args) { + super(message, args); + } +} diff --git a/src/main/java/dev/sorn/fmp4j/types/FmpApiKey.java b/src/main/java/dev/sorn/fmp4j/types/FmpApiKey.java new file mode 100644 index 00000000..851f1726 --- /dev/null +++ b/src/main/java/dev/sorn/fmp4j/types/FmpApiKey.java @@ -0,0 +1,52 @@ +package dev.sorn.fmp4j.types; + +import static java.lang.String.format; + +import dev.sorn.fmp4j.exceptions.FmpInvalidApiKey; +import java.util.regex.Pattern; + +public class FmpApiKey implements FmpValueObject { + public static final Pattern FMP_API_KEY_PATTERN = Pattern.compile("^[A-Za-z\\d]{32}$"); + + private final String value; + + public FmpApiKey(String value) { + if (value == null || value.isBlank()) { + throw new FmpInvalidApiKey("'value' must not be null or blank"); + } + if (!FMP_API_KEY_PATTERN.matcher(value).matches()) { + throw new FmpInvalidApiKey( + format("'value' [%s] does not match pattern [%s]", value, FMP_API_KEY_PATTERN.pattern())); + } + this.value = value; + } + + @Override + public String value() { + return value.substring(0, 2) + "*".repeat(value.length() - 4) + value.substring(value.length() - 2); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (!(obj instanceof FmpApiKey that)) { + return false; + } + return value.equals(that.value); + } + + @Override + public int hashCode() { + return value.hashCode(); + } + + @Override + public String toString() { + return value(); + } +} diff --git a/src/test/java/dev/sorn/fmp4j/FmpClientTest.java b/src/test/java/dev/sorn/fmp4j/FmpClientTest.java index 1127c674..4f969a28 100644 --- a/src/test/java/dev/sorn/fmp4j/FmpClientTest.java +++ b/src/test/java/dev/sorn/fmp4j/FmpClientTest.java @@ -70,6 +70,7 @@ import dev.sorn.fmp4j.models.FmpStock; import dev.sorn.fmp4j.models.FmpStockPriceChange; import dev.sorn.fmp4j.models.FmpTreasuryRate; +import dev.sorn.fmp4j.types.FmpApiKey; import dev.sorn.fmp4j.types.FmpSymbol; import java.net.URI; import java.time.LocalDate; @@ -86,7 +87,7 @@ class FmpClientTest { private static final String BASE_URL = "https://financialmodelingprep.com/stable"; - private static final String API_KEY = "424242"; + private static final FmpApiKey API_KEY = new FmpApiKey("ABCDEf0ghIjklmNO1pqRsT2u34VWx5y6"); private final FmpConfig fmpConfig = mock(FmpConfig.class); private final FmpHttpClient fmpHttpClient = mock(FmpHttpClient.class); private FmpClient fmpClient; diff --git a/src/test/java/dev/sorn/fmp4j/cfg/FmpConfigImplTest.java b/src/test/java/dev/sorn/fmp4j/cfg/FmpConfigImplTest.java index b6d8f47e..956ff926 100644 --- a/src/test/java/dev/sorn/fmp4j/cfg/FmpConfigImplTest.java +++ b/src/test/java/dev/sorn/fmp4j/cfg/FmpConfigImplTest.java @@ -26,7 +26,7 @@ void load_fmp_api_key_from_env() { // then assertNotNull(fmpApiKey); - assertEquals("ABCDEf0ghIjklmNO1pqRsT2u34VWx5y6", fmpApiKey); + assertEquals("AB****************************y6", fmpApiKey.value()); } @Test diff --git a/src/test/java/dev/sorn/fmp4j/clients/FmpChartClientTest.java b/src/test/java/dev/sorn/fmp4j/clients/FmpChartClientTest.java index 6f734f2d..d8702961 100644 --- a/src/test/java/dev/sorn/fmp4j/clients/FmpChartClientTest.java +++ b/src/test/java/dev/sorn/fmp4j/clients/FmpChartClientTest.java @@ -5,6 +5,7 @@ import dev.sorn.fmp4j.cfg.FmpConfig; import dev.sorn.fmp4j.http.FmpHttpClient; +import dev.sorn.fmp4j.types.FmpApiKey; import org.junit.jupiter.api.BeforeEach; class FmpChartClientTest { @@ -14,7 +15,7 @@ class FmpChartClientTest { @BeforeEach void setUp() { - when(cfg.fmpApiKey()).thenReturn("abc123"); + when(cfg.fmpApiKey()).thenReturn(new FmpApiKey("ABCDEf0ghIjklmNO1pqRsT2u34VWx5y6")); client = new FmpChartClient(cfg, http); } } diff --git a/src/test/java/dev/sorn/fmp4j/types/FmpApiKeyTest.java b/src/test/java/dev/sorn/fmp4j/types/FmpApiKeyTest.java new file mode 100644 index 00000000..1496f448 --- /dev/null +++ b/src/test/java/dev/sorn/fmp4j/types/FmpApiKeyTest.java @@ -0,0 +1,149 @@ +package dev.sorn.fmp4j.types; + +import static dev.sorn.fmp4j.types.FmpApiKey.FMP_API_KEY_PATTERN; +import static java.lang.String.format; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.sorn.fmp4j.exceptions.FmpInvalidApiKey; +import java.util.function.Function; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class FmpApiKeyTest { + @Test + void sensitive_data_is_masked() { + // given + var apiKey = new FmpApiKey("ABCDEf0ghIjklmNO1pqRsT2u34VWx5y6"); + + // when + var val = apiKey.value(); + var str = apiKey.toString(); + + // then + assertEquals("AB****************************y6", val); + assertEquals("AB****************************y6", str); + } + + @Test + void null_key_throws() { + // given + var nullKey = (String) null; + + // when + Function f = FmpApiKey::new; + + // then + var e = assertThrows(FmpInvalidApiKey.class, () -> f.apply(nullKey)); + assertEquals("'value' must not be null or blank", e.getMessage()); + } + + @Test + void blank_key_throws() { + // given + var blankKey = ""; + + // when + Function f = FmpApiKey::new; + + // then + var e = assertThrows(FmpInvalidApiKey.class, () -> f.apply(blankKey)); + assertEquals("'value' must not be null or blank", e.getMessage()); + } + + @ParameterizedTest + @ValueSource( + strings = { + "ABCDEf0ghIjklmNO1pqRsT2u34VWx5y", // too short + "ABCDEf0ghIjklmNO1pqRsT2u34VWx5y6z", // too long + "ABCDEf0ghIjklmN$1pqRsT2u34VWx5y6" // invalid symbol $ + }) + void invalid_pattern_throws(String value) { + // when + Function f = FmpApiKey::new; + + // then + var e = assertThrows(FmpInvalidApiKey.class, () -> f.apply(value)); + assertEquals(format("'value' [%s] does not match pattern [%s]", value, FMP_API_KEY_PATTERN), e.getMessage()); + } + + @Test + void hashCode_value() { + // given + var str = "ABCDEf0ghIjklmNO1pqRsT2u34VWx5y6"; + var k = new FmpApiKey("ABCDEf0ghIjklmNO1pqRsT2u34VWx5y6"); + + // when + var hc = k.hashCode(); + + // then + assertEquals(str.hashCode(), hc); + } + + @Test + void equals_same_true() { + // given + var k = new FmpApiKey("ABCDEf0ghIjklmNO1pqRsT2u34VWx5y6"); + + // when + var eq = k.equals(k); + + // then + assertTrue(eq); + } + + @Test + void equals_identical_true() { + // given + var k1 = new FmpApiKey("ABCDEf0ghIjklmNO1pqRsT2u34VWx5y6"); + var k2 = new FmpApiKey("ABCDEf0ghIjklmNO1pqRsT2u34VWx5y6"); + + // when + var eq = k1.equals(k2); + + // then + assertTrue(eq); + } + + @Test + void equals_null_false() { + // given + var k1 = new FmpApiKey("ABCDEf0ghIjklmNO1pqRsT2u34VWx5y6"); + var k2 = (FmpApiKey) null; + + // when + var eq = k1.equals(k2); + + // then + assertFalse(eq); + } + + @Test + void equals_different_false() { + // given + var k1 = new FmpApiKey("ABCDEf0ghIjklmNO1pqRsT2u34VWx5y6"); + var k2 = new FmpApiKey("ABcDEf0ghIjklmNO1pqRsT2u34VWx5y6"); + + // when + var eq = k1.equals(k2); + + // then + assertFalse(eq); + } + + @Test + void equals_wrong_instance_false() { + // given + var k1 = new FmpApiKey("ABCDEf0ghIjklmNO1pqRsT2u34VWx5y6"); + var k2 = "ABCDEf0ghIjklmNO1pqRsT2u34VWx5y6"; + + // when + var eq = k1.equals(k2); + + // then + assertFalse(eq); + } +}