diff --git a/examples/java/io/mailtrap/examples/general/ApiTokensExample.java b/examples/java/io/mailtrap/examples/general/ApiTokensExample.java index e738dcd..c830df3 100644 --- a/examples/java/io/mailtrap/examples/general/ApiTokensExample.java +++ b/examples/java/io/mailtrap/examples/general/ApiTokensExample.java @@ -6,7 +6,10 @@ import io.mailtrap.model.ResourceType; import io.mailtrap.model.request.apitokens.ApiTokenResource; import io.mailtrap.model.request.apitokens.CreateApiTokenRequest; +import io.mailtrap.model.request.apitokens.ResetApiTokenRequest; +import io.mailtrap.model.request.apitokens.TokenExpiration; +import java.time.OffsetDateTime; import java.util.List; public class ApiTokensExample { @@ -22,8 +25,13 @@ public static void main(String[] args) { final var client = MailtrapClientFactory.createMailtrapClient(config); // The full token value is returned only on creation — store it securely. + // Expiration is optional: omit it for the server default (a 1-year default is being + // rolled out), pass TokenExpiration.never() for a token that never expires, or pass + // TokenExpiration.at(...) for a concrete expiration (must be in the future and no + // more than 5 years ahead, otherwise the API responds with a 422 error). final var createRequest = new CreateApiTokenRequest( "My token", + TokenExpiration.at(OffsetDateTime.now().plusMonths(6)), List.of(new ApiTokenResource(ResourceType.ACCOUNT, ACCOUNT_ID, AccessLevel.VIEWER))); final var createdToken = client.generalApi().apiTokens() @@ -39,8 +47,11 @@ public static void main(String[] args) { System.out.println(token); // Reset expires the existing token and returns a new one with the same permissions. - // The new token value is only returned here. - final var resetToken = client.generalApi().apiTokens().resetApiToken(ACCOUNT_ID, tokenId); + // The new token value is only returned here. Without a request body the new token + // gets the server default expiration; the overload with ResetApiTokenRequest sets it + // explicitly (here: a token that never expires). + final var resetToken = client.generalApi().apiTokens() + .resetApiToken(ACCOUNT_ID, tokenId, new ResetApiTokenRequest(TokenExpiration.never())); System.out.println(resetToken); client.generalApi().apiTokens().deleteApiToken(ACCOUNT_ID, resetToken.getId()); diff --git a/src/main/java/io/mailtrap/api/apitokens/ApiTokens.java b/src/main/java/io/mailtrap/api/apitokens/ApiTokens.java index 597921e..3a30473 100644 --- a/src/main/java/io/mailtrap/api/apitokens/ApiTokens.java +++ b/src/main/java/io/mailtrap/api/apitokens/ApiTokens.java @@ -1,6 +1,7 @@ package io.mailtrap.api.apitokens; import io.mailtrap.model.request.apitokens.CreateApiTokenRequest; +import io.mailtrap.model.request.apitokens.ResetApiTokenRequest; import io.mailtrap.model.response.apitokens.ApiToken; import io.mailtrap.model.response.apitokens.ApiTokenWithToken; @@ -53,4 +54,16 @@ public interface ApiTokens { */ ApiTokenWithToken resetApiToken(long accountId, long id); + /** + * Reset an API token. Expires the requested token and creates a new one with the same + * permissions; the new token value is returned only once. The request can set the new + * token expiration; omit it for the server default. + * + * @param accountId unique account ID + * @param id API token ID + * @param request optional new token expiration + * @return new token, including the full token value + */ + ApiTokenWithToken resetApiToken(long accountId, long id, ResetApiTokenRequest request); + } diff --git a/src/main/java/io/mailtrap/api/apitokens/ApiTokensImpl.java b/src/main/java/io/mailtrap/api/apitokens/ApiTokensImpl.java index 9a67eae..6eb53ee 100644 --- a/src/main/java/io/mailtrap/api/apitokens/ApiTokensImpl.java +++ b/src/main/java/io/mailtrap/api/apitokens/ApiTokensImpl.java @@ -6,6 +6,7 @@ import io.mailtrap.http.RequestData; import io.mailtrap.model.AbstractModel; import io.mailtrap.model.request.apitokens.CreateApiTokenRequest; +import io.mailtrap.model.request.apitokens.ResetApiTokenRequest; import io.mailtrap.model.response.apitokens.ApiToken; import io.mailtrap.model.response.apitokens.ApiTokenWithToken; @@ -64,4 +65,14 @@ public ApiTokenWithToken resetApiToken(final long accountId, final long id) { ApiTokenWithToken.class ); } + + @Override + public ApiTokenWithToken resetApiToken(final long accountId, final long id, final ResetApiTokenRequest request) { + return httpClient.post( + String.format(apiHost + "/api/accounts/%d/api_tokens/%d/reset", accountId, id), + request, + new RequestData(), + ApiTokenWithToken.class + ); + } } diff --git a/src/main/java/io/mailtrap/model/request/apitokens/CreateApiTokenRequest.java b/src/main/java/io/mailtrap/model/request/apitokens/CreateApiTokenRequest.java index e2c9a7f..5543421 100644 --- a/src/main/java/io/mailtrap/model/request/apitokens/CreateApiTokenRequest.java +++ b/src/main/java/io/mailtrap/model/request/apitokens/CreateApiTokenRequest.java @@ -1,5 +1,7 @@ package io.mailtrap.model.request.apitokens; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; import io.mailtrap.model.AbstractModel; import lombok.AllArgsConstructor; import lombok.Getter; @@ -16,6 +18,19 @@ public class CreateApiTokenRequest extends AbstractModel { private String name; + /** + * Optional token expiration as an ISO 8601 date-time. Omit (or leave null) for the server + * default (a 1-year default is being rolled out). Use {@link TokenExpiration#never()} for + * a token that never expires. Past or more-than-5-years-ahead values are rejected with 422. + */ + @JsonProperty("expires_at") + @JsonInclude(JsonInclude.Include.NON_NULL) + private TokenExpiration expiresAt; + private List resources; + public CreateApiTokenRequest(final String name, final List resources) { + this(name, null, resources); + } + } diff --git a/src/main/java/io/mailtrap/model/request/apitokens/ResetApiTokenRequest.java b/src/main/java/io/mailtrap/model/request/apitokens/ResetApiTokenRequest.java new file mode 100644 index 0000000..b2da7e8 --- /dev/null +++ b/src/main/java/io/mailtrap/model/request/apitokens/ResetApiTokenRequest.java @@ -0,0 +1,26 @@ +package io.mailtrap.model.request.apitokens; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.mailtrap.model.AbstractModel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class ResetApiTokenRequest extends AbstractModel { + + /** + * Optional token expiration as an ISO 8601 date-time. Omit (or leave null) for the server + * default (a 1-year default is being rolled out). Use {@link TokenExpiration#never()} for + * a token that never expires. Past or more-than-5-years-ahead values are rejected with 422. + */ + @JsonProperty("expires_at") + @JsonInclude(JsonInclude.Include.NON_NULL) + private TokenExpiration expiresAt; + +} diff --git a/src/main/java/io/mailtrap/model/request/apitokens/TokenExpiration.java b/src/main/java/io/mailtrap/model/request/apitokens/TokenExpiration.java new file mode 100644 index 0000000..f1edf0a --- /dev/null +++ b/src/main/java/io/mailtrap/model/request/apitokens/TokenExpiration.java @@ -0,0 +1,45 @@ +package io.mailtrap.model.request.apitokens; + +import com.fasterxml.jackson.annotation.JsonValue; + +import java.time.OffsetDateTime; +import java.time.format.DateTimeFormatter; + +/** + * API token expiration sent as the {@code expires_at} request field. + * Use {@link #at(OffsetDateTime)} for a concrete expiration or {@link #never()} for a token + * that never expires (serialized as JSON {@code null}). + */ +public final class TokenExpiration { + + private final String value; + + private TokenExpiration(final String value) { + this.value = value; + } + + /** + * Token expires at the given moment. Past or more-than-5-years-ahead values are rejected + * by the API with a 422 error. + * + * @param value expiration date-time + * @return expiration serialized as an ISO 8601 date-time string + */ + public static TokenExpiration at(final OffsetDateTime value) { + return new TokenExpiration(DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(value)); + } + + /** + * Token never expires. + * + * @return expiration serialized as JSON {@code null} + */ + public static TokenExpiration never() { + return new TokenExpiration(null); + } + + @JsonValue + public String getValue() { + return value; + } +} diff --git a/src/main/java/io/mailtrap/model/response/accountaccesses/ApiTokenSpecifier.java b/src/main/java/io/mailtrap/model/response/accountaccesses/ApiTokenSpecifier.java index d42f428..b49baf2 100644 --- a/src/main/java/io/mailtrap/model/response/accountaccesses/ApiTokenSpecifier.java +++ b/src/main/java/io/mailtrap/model/response/accountaccesses/ApiTokenSpecifier.java @@ -17,6 +17,9 @@ public class ApiTokenSpecifier extends Specifier { private String token; + @JsonProperty("masked_token") + private String maskedToken; + @JsonProperty("expires_at") private OffsetDateTime expiresAt; diff --git a/src/test/java/io/mailtrap/api/apitokens/ApiTokensImplTest.java b/src/test/java/io/mailtrap/api/apitokens/ApiTokensImplTest.java index ffff7cf..3b1ad05 100644 --- a/src/test/java/io/mailtrap/api/apitokens/ApiTokensImplTest.java +++ b/src/test/java/io/mailtrap/api/apitokens/ApiTokensImplTest.java @@ -1,12 +1,19 @@ package io.mailtrap.api.apitokens; +import com.fasterxml.jackson.databind.JsonNode; import io.mailtrap.Constants; +import io.mailtrap.Mapper; import io.mailtrap.config.MailtrapConfig; +import io.mailtrap.exception.http.HttpClientException; import io.mailtrap.factory.MailtrapClientFactory; +import io.mailtrap.http.CustomHttpClient; +import io.mailtrap.http.RequestData; import io.mailtrap.model.AccessLevel; import io.mailtrap.model.ResourceType; import io.mailtrap.model.request.apitokens.ApiTokenResource; import io.mailtrap.model.request.apitokens.CreateApiTokenRequest; +import io.mailtrap.model.request.apitokens.ResetApiTokenRequest; +import io.mailtrap.model.request.apitokens.TokenExpiration; import io.mailtrap.model.response.apitokens.ApiToken; import io.mailtrap.model.response.apitokens.ApiTokenWithToken; import io.mailtrap.testutils.BaseTest; @@ -15,15 +22,26 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import java.io.IOException; +import java.time.OffsetDateTime; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; class ApiTokensImplTest extends BaseTest { private final long apiTokenId = 12345L; + private final long resetWithBodyApiTokenId = 54321L; private ApiTokens api; @@ -36,6 +54,12 @@ public void init() { DataMock.build(Constants.GENERAL_HOST + "/api/accounts/" + accountId + "/api_tokens", "POST", "api/apitokens/createApiTokenRequest.json", "api/apitokens/createApiTokenResponse.json"), + DataMock.build(Constants.GENERAL_HOST + "/api/accounts/" + accountId + "/api_tokens", + "POST", "api/apitokens/createApiTokenNeverExpiresRequest.json", "api/apitokens/createApiTokenNeverExpiresResponse.json"), + + DataMock.build(Constants.GENERAL_HOST + "/api/accounts/" + accountId + "/api_tokens", + "POST", "api/apitokens/createApiTokenWithExpirationRequest.json", "api/apitokens/createApiTokenWithExpirationResponse.json"), + DataMock.build(Constants.GENERAL_HOST + "/api/accounts/" + accountId + "/api_tokens/" + apiTokenId, "GET", null, "api/apitokens/getApiTokenResponse.json"), @@ -43,7 +67,13 @@ public void init() { "DELETE", null, null), DataMock.build(Constants.GENERAL_HOST + "/api/accounts/" + accountId + "/api_tokens/" + apiTokenId + "/reset", - "POST", null, "api/apitokens/resetApiTokenResponse.json") + "POST", null, "api/apitokens/resetApiTokenResponse.json"), + + DataMock.build(Constants.GENERAL_HOST + "/api/accounts/" + accountId + "/api_tokens/" + resetWithBodyApiTokenId + "/reset", + "POST", "api/apitokens/resetApiTokenNeverExpiresRequest.json", "api/apitokens/resetApiTokenNeverExpiresResponse.json"), + + DataMock.build(Constants.GENERAL_HOST + "/api/accounts/" + accountId + "/api_tokens/" + resetWithBodyApiTokenId + "/reset", + "POST", "api/apitokens/resetApiTokenWithExpirationRequest.json", "api/apitokens/resetApiTokenWithExpirationResponse.json") )); final MailtrapConfig testConfig = new MailtrapConfig.Builder() @@ -69,12 +99,15 @@ void test_getAllApiTokens() { } @Test - void test_createApiToken() { + void test_createApiToken() throws IOException { final CreateApiTokenRequest request = new CreateApiTokenRequest( "Scratch test token", List.of(new ApiTokenResource(ResourceType.ACCOUNT, accountId, AccessLevel.ADMIN)) ); + final JsonNode body = Mapper.get().readTree(request.toJson()); + assertFalse(body.has("expires_at")); + final ApiTokenWithToken response = api.createApiToken(accountId, request); assertNotNull(response); @@ -84,6 +117,69 @@ void test_createApiToken() { assertNull(response.getExpiresAt()); } + @Test + void test_createApiToken_neverExpires() throws IOException { + final CreateApiTokenRequest request = new CreateApiTokenRequest( + "Never expiring token", + TokenExpiration.never(), + List.of(new ApiTokenResource(ResourceType.ACCOUNT, accountId, AccessLevel.ADMIN)) + ); + + final JsonNode body = Mapper.get().readTree(request.toJson()); + assertTrue(body.has("expires_at")); + assertTrue(body.get("expires_at").isNull()); + + final ApiTokenWithToken response = api.createApiToken(accountId, request); + + assertNotNull(response); + assertEquals(23456L, response.getId()); + assertEquals("neverexpires123", response.getToken()); + assertNull(response.getExpiresAt()); + } + + @Test + void test_createApiToken_withExpiration() throws IOException { + final CreateApiTokenRequest request = new CreateApiTokenRequest( + "Expiring token", + TokenExpiration.at(OffsetDateTime.parse("2027-06-01T00:00:00Z")), + List.of(new ApiTokenResource(ResourceType.ACCOUNT, accountId, AccessLevel.ADMIN)) + ); + + final JsonNode body = Mapper.get().readTree(request.toJson()); + assertEquals("2027-06-01T00:00:00Z", body.get("expires_at").asText()); + + final ApiTokenWithToken response = api.createApiToken(accountId, request); + + assertNotNull(response); + assertEquals(34567L, response.getId()); + assertEquals("expiring123", response.getToken()); + assertEquals(OffsetDateTime.parse("2027-06-01T00:00:00Z"), response.getExpiresAt()); + } + + @Test + void test_createApiToken_invalidExpiration_throwsHttpClientException() { + final CustomHttpClient failingHttpClient = mock(CustomHttpClient.class); + when(failingHttpClient.post(anyString(), any(CreateApiTokenRequest.class), any(RequestData.class), eq(ApiTokenWithToken.class))) + .thenThrow(new HttpClientException("Expires at must be no more than 5 years in the future", 422)); + + final MailtrapConfig failingConfig = new MailtrapConfig.Builder() + .httpClient(failingHttpClient) + .token("dummy_token") + .build(); + + final ApiTokens failingApi = MailtrapClientFactory.createMailtrapClient(failingConfig).generalApi().apiTokens(); + + final CreateApiTokenRequest request = new CreateApiTokenRequest( + "Token with invalid expiration", + TokenExpiration.at(OffsetDateTime.parse("2050-01-01T00:00:00Z")), + List.of(new ApiTokenResource(ResourceType.ACCOUNT, accountId, AccessLevel.ADMIN)) + ); + + final HttpClientException exception = assertThrows(HttpClientException.class, + () -> failingApi.createApiToken(accountId, request)); + assertEquals(422, exception.getStatusCode()); + } + @Test void test_getApiToken() { final ApiToken token = api.getApiToken(accountId, apiTokenId); @@ -108,4 +204,37 @@ void test_resetApiToken() { assertEquals("newtoken123", response.getToken()); assertEquals("n3w0", response.getLast4Digits()); } + + @Test + void test_resetApiToken_neverExpires() throws IOException { + final ResetApiTokenRequest request = new ResetApiTokenRequest(TokenExpiration.never()); + + final JsonNode body = Mapper.get().readTree(request.toJson()); + assertTrue(body.has("expires_at")); + assertTrue(body.get("expires_at").isNull()); + + final ApiTokenWithToken response = api.resetApiToken(accountId, resetWithBodyApiTokenId, request); + + assertNotNull(response); + assertEquals(resetWithBodyApiTokenId, response.getId()); + assertEquals("resetnever123", response.getToken()); + assertNull(response.getExpiresAt()); + } + + @Test + void test_resetApiToken_withExpiration() throws IOException { + final ResetApiTokenRequest request = new ResetApiTokenRequest( + TokenExpiration.at(OffsetDateTime.parse("2027-06-01T00:00:00Z")) + ); + + final JsonNode body = Mapper.get().readTree(request.toJson()); + assertEquals("2027-06-01T00:00:00Z", body.get("expires_at").asText()); + + final ApiTokenWithToken response = api.resetApiToken(accountId, resetWithBodyApiTokenId, request); + + assertNotNull(response); + assertEquals(resetWithBodyApiTokenId, response.getId()); + assertEquals("resetexpiring123", response.getToken()); + assertEquals(OffsetDateTime.parse("2027-06-01T00:00:00Z"), response.getExpiresAt()); + } } diff --git a/src/test/java/io/mailtrap/serialization/AccountAccessResponseDeserializerTest.java b/src/test/java/io/mailtrap/serialization/AccountAccessResponseDeserializerTest.java index cc6bfb5..30c661b 100644 --- a/src/test/java/io/mailtrap/serialization/AccountAccessResponseDeserializerTest.java +++ b/src/test/java/io/mailtrap/serialization/AccountAccessResponseDeserializerTest.java @@ -26,6 +26,7 @@ void testApiTokenSpecifierDeserialization() throws Exception { "id": 1, "name": "Token", "token": "xyz", + "masked_token": "*******xyz", "expires_at": "2025-01-01T00:00:00Z" }, "resources": [], @@ -46,6 +47,7 @@ void testApiTokenSpecifierDeserialization() throws Exception { assertEquals(1, apiTokenSpecifier.getId()); assertEquals("Token", apiTokenSpecifier.getName()); assertEquals("xyz", apiTokenSpecifier.getToken()); + assertEquals("*******xyz", apiTokenSpecifier.getMaskedToken()); assertEquals(OffsetDateTime.parse("2025-01-01T00:00:00Z"), apiTokenSpecifier.getExpiresAt()); } diff --git a/src/test/resources/api/apitokens/createApiTokenNeverExpiresRequest.json b/src/test/resources/api/apitokens/createApiTokenNeverExpiresRequest.json new file mode 100644 index 0000000..f519619 --- /dev/null +++ b/src/test/resources/api/apitokens/createApiTokenNeverExpiresRequest.json @@ -0,0 +1,11 @@ +{ + "name": "Never expiring token", + "expires_at": null, + "resources": [ + { + "resource_type": "account", + "resource_id": 1, + "access_level": "admin" + } + ] +} diff --git a/src/test/resources/api/apitokens/createApiTokenNeverExpiresResponse.json b/src/test/resources/api/apitokens/createApiTokenNeverExpiresResponse.json new file mode 100644 index 0000000..5dd8deb --- /dev/null +++ b/src/test/resources/api/apitokens/createApiTokenNeverExpiresResponse.json @@ -0,0 +1,15 @@ +{ + "id": 23456, + "name": "Never expiring token", + "last_4_digits": "q8r2", + "created_by": "user@example.com", + "expires_at": null, + "resources": [ + { + "resource_type": "account", + "resource_id": 1, + "access_level": 100 + } + ], + "token": "neverexpires123" +} diff --git a/src/test/resources/api/apitokens/createApiTokenWithExpirationRequest.json b/src/test/resources/api/apitokens/createApiTokenWithExpirationRequest.json new file mode 100644 index 0000000..6ae6780 --- /dev/null +++ b/src/test/resources/api/apitokens/createApiTokenWithExpirationRequest.json @@ -0,0 +1,11 @@ +{ + "name": "Expiring token", + "expires_at": "2027-06-01T00:00:00Z", + "resources": [ + { + "resource_type": "account", + "resource_id": 1, + "access_level": "admin" + } + ] +} diff --git a/src/test/resources/api/apitokens/createApiTokenWithExpirationResponse.json b/src/test/resources/api/apitokens/createApiTokenWithExpirationResponse.json new file mode 100644 index 0000000..bd34223 --- /dev/null +++ b/src/test/resources/api/apitokens/createApiTokenWithExpirationResponse.json @@ -0,0 +1,15 @@ +{ + "id": 34567, + "name": "Expiring token", + "last_4_digits": "z5w1", + "created_by": "user@example.com", + "expires_at": "2027-06-01T00:00:00Z", + "resources": [ + { + "resource_type": "account", + "resource_id": 1, + "access_level": 100 + } + ], + "token": "expiring123" +} diff --git a/src/test/resources/api/apitokens/resetApiTokenNeverExpiresRequest.json b/src/test/resources/api/apitokens/resetApiTokenNeverExpiresRequest.json new file mode 100644 index 0000000..e147564 --- /dev/null +++ b/src/test/resources/api/apitokens/resetApiTokenNeverExpiresRequest.json @@ -0,0 +1,3 @@ +{ + "expires_at": null +} diff --git a/src/test/resources/api/apitokens/resetApiTokenNeverExpiresResponse.json b/src/test/resources/api/apitokens/resetApiTokenNeverExpiresResponse.json new file mode 100644 index 0000000..da0b2a6 --- /dev/null +++ b/src/test/resources/api/apitokens/resetApiTokenNeverExpiresResponse.json @@ -0,0 +1,15 @@ +{ + "id": 54321, + "name": "My API Token", + "last_4_digits": "nv3r", + "created_by": "user@example.com", + "expires_at": null, + "resources": [ + { + "resource_type": "account", + "resource_id": 1, + "access_level": 100 + } + ], + "token": "resetnever123" +} diff --git a/src/test/resources/api/apitokens/resetApiTokenWithExpirationRequest.json b/src/test/resources/api/apitokens/resetApiTokenWithExpirationRequest.json new file mode 100644 index 0000000..561782e --- /dev/null +++ b/src/test/resources/api/apitokens/resetApiTokenWithExpirationRequest.json @@ -0,0 +1,3 @@ +{ + "expires_at": "2027-06-01T00:00:00Z" +} diff --git a/src/test/resources/api/apitokens/resetApiTokenWithExpirationResponse.json b/src/test/resources/api/apitokens/resetApiTokenWithExpirationResponse.json new file mode 100644 index 0000000..be40efb --- /dev/null +++ b/src/test/resources/api/apitokens/resetApiTokenWithExpirationResponse.json @@ -0,0 +1,15 @@ +{ + "id": 54321, + "name": "My API Token", + "last_4_digits": "exp1", + "created_by": "user@example.com", + "expires_at": "2027-06-01T00:00:00Z", + "resources": [ + { + "resource_type": "account", + "resource_id": 1, + "access_level": 100 + } + ], + "token": "resetexpiring123" +}