JAMES-2366 Introduce REST API for forward
Project: http://git-wip-us.apache.org/repos/asf/james-project/repo Commit: http://git-wip-us.apache.org/repos/asf/james-project/commit/92f4a682 Tree: http://git-wip-us.apache.org/repos/asf/james-project/tree/92f4a682 Diff: http://git-wip-us.apache.org/repos/asf/james-project/diff/92f4a682 Branch: refs/heads/master Commit: 92f4a6823a22164341135eb08603625f02449dc3 Parents: 53d760b Author: benwa <btell...@linagora.com> Authored: Thu Mar 29 16:16:07 2018 +0700 Committer: Antoine Duprat <adup...@linagora.com> Committed: Fri Apr 6 15:04:48 2018 +0200 ---------------------------------------------------------------------- .../dto/ForwardDestinationResponse.java | 32 + .../james/webadmin/routes/ForwardRoutes.java | 263 ++++++++ .../webadmin/routes/ForwardRoutesTest.java | 676 +++++++++++++++++++ 3 files changed, 971 insertions(+) ---------------------------------------------------------------------- http://git-wip-us.apache.org/repos/asf/james-project/blob/92f4a682/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/dto/ForwardDestinationResponse.java ---------------------------------------------------------------------- diff --git a/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/dto/ForwardDestinationResponse.java b/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/dto/ForwardDestinationResponse.java new file mode 100644 index 0000000..5e681c0 --- /dev/null +++ b/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/dto/ForwardDestinationResponse.java @@ -0,0 +1,32 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.webadmin.dto; + +public class ForwardDestinationResponse { + private final String mailAddress; + + public ForwardDestinationResponse(String mailAddress) { + this.mailAddress = mailAddress; + } + + public String getMailAddress() { + return mailAddress; + } +} http://git-wip-us.apache.org/repos/asf/james-project/blob/92f4a682/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/routes/ForwardRoutes.java ---------------------------------------------------------------------- diff --git a/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/routes/ForwardRoutes.java b/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/routes/ForwardRoutes.java new file mode 100644 index 0000000..ffca23c --- /dev/null +++ b/server/protocols/webadmin/webadmin-data/src/main/java/org/apache/james/webadmin/routes/ForwardRoutes.java @@ -0,0 +1,263 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.webadmin.routes; + +import static org.apache.james.webadmin.Constants.SEPARATOR; +import static spark.Spark.halt; + +import java.io.UnsupportedEncodingException; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import javax.inject.Inject; +import javax.mail.internet.AddressException; +import javax.ws.rs.DELETE; +import javax.ws.rs.GET; +import javax.ws.rs.PUT; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; + +import org.apache.james.core.MailAddress; +import org.apache.james.domainlist.api.DomainListException; +import org.apache.james.rrt.api.RecipientRewriteTable; +import org.apache.james.rrt.api.RecipientRewriteTableException; +import org.apache.james.rrt.lib.Mapping; +import org.apache.james.rrt.lib.Mappings; +import org.apache.james.user.api.UsersRepository; +import org.apache.james.user.api.UsersRepositoryException; +import org.apache.james.webadmin.Constants; +import org.apache.james.webadmin.Routes; +import org.apache.james.webadmin.dto.ForwardDestinationResponse; +import org.apache.james.webadmin.utils.ErrorResponder; +import org.apache.james.webadmin.utils.ErrorResponder.ErrorType; +import org.apache.james.webadmin.utils.JsonExtractException; +import org.apache.james.webadmin.utils.JsonTransformer; +import org.eclipse.jetty.http.HttpStatus; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.github.steveash.guavate.Guavate; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.ImmutableSortedSet; + +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiImplicitParam; +import io.swagger.annotations.ApiImplicitParams; +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiResponse; +import io.swagger.annotations.ApiResponses; +import spark.HaltException; +import spark.Request; +import spark.Response; +import spark.Service; + +@Api(tags = "Address Forwards") +@Path(ForwardRoutes.ROOT_PATH) +@Produces(Constants.JSON_CONTENT_TYPE) +public class ForwardRoutes implements Routes { + + public static final String ROOT_PATH = "address/forwards"; + + private static final Logger LOGGER = LoggerFactory.getLogger(ForwardRoutes.class); + + private static final String FORWARD_BASE_ADDRESS = "forwardBaseAddress"; + private static final String FORWARD_ADDRESS_PATH = ROOT_PATH + SEPARATOR + ":" + FORWARD_BASE_ADDRESS; + private static final String FORWARD_DESTINATION_ADDRESS = "forwardDestinationAddress"; + private static final String USER_IN_FORWARD_DESTINATION_ADDRESSES_PATH = FORWARD_ADDRESS_PATH + SEPARATOR + + "targets" + SEPARATOR + ":" + FORWARD_DESTINATION_ADDRESS; + private static final String MAILADDRESS_ASCII_DISCLAIMER = "Note that email addresses are restricted to ASCII character set. " + + "Mail addresses not matching this criteria will be rejected."; + + private final UsersRepository usersRepository; + private final JsonTransformer jsonTransformer; + private final RecipientRewriteTable recipientRewriteTable; + + @Inject + @VisibleForTesting + ForwardRoutes(RecipientRewriteTable recipientRewriteTable, UsersRepository usersRepository, JsonTransformer jsonTransformer) { + this.usersRepository = usersRepository; + this.jsonTransformer = jsonTransformer; + this.recipientRewriteTable = recipientRewriteTable; + } + + @Override + public void define(Service service) { + service.get(ROOT_PATH, this::listForwards, jsonTransformer); + service.get(FORWARD_ADDRESS_PATH, this::listForwardDestinations, jsonTransformer); + service.put(FORWARD_ADDRESS_PATH, this::throwUnknownPath); + service.put(USER_IN_FORWARD_DESTINATION_ADDRESSES_PATH, this::addToForwardDestinations); + service.delete(FORWARD_ADDRESS_PATH, this::throwUnknownPath); + service.delete(USER_IN_FORWARD_DESTINATION_ADDRESSES_PATH, this::removeFromForwardDestination); + } + + public Object throwUnknownPath(Request request, Response response) { + throw ErrorResponder.builder() + .statusCode(HttpStatus.BAD_REQUEST_400) + .type(ErrorType.INVALID_ARGUMENT) + .message("An destination address needs to be specified in the path") + .haltError(); + } + + @GET + @Path(ROOT_PATH) + @ApiOperation(value = "getting forwards list") + @ApiResponses(value = { + @ApiResponse(code = HttpStatus.OK_200, message = "OK", response = List.class), + @ApiResponse(code = HttpStatus.INTERNAL_SERVER_ERROR_500, + message = "Internal server error - Something went bad on the server side.") + }) + public Set<String> listForwards(Request request, Response response) throws RecipientRewriteTableException { + return Optional.ofNullable(recipientRewriteTable.getAllMappings()) + .map(mappings -> + mappings.entrySet().stream() + .filter(e -> e.getValue().contains(Mapping.Type.Forward)) + .map(Map.Entry::getKey) + .collect(Guavate.toImmutableSortedSet())) + .orElse(ImmutableSortedSet.of()); + } + + @PUT + @Path(ROOT_PATH + "/{" + FORWARD_BASE_ADDRESS + "}/targets/{" + FORWARD_DESTINATION_ADDRESS + "}") + @ApiOperation(value = "adding a destination address into a forward") + @ApiImplicitParams({ + @ApiImplicitParam(required = true, dataType = "string", name = FORWARD_BASE_ADDRESS, paramType = "path", + value = "Base mail address of the forward. Sending a mail to that address will send it to all forward destinations.\n" + + MAILADDRESS_ASCII_DISCLAIMER), + @ApiImplicitParam(required = true, dataType = "string", name = FORWARD_DESTINATION_ADDRESS, paramType = "path", + value = "A destination mail address of the forward. Sending a mail to the base address will send an email to " + + "that email address (as well as other destinations).\n" + + MAILADDRESS_ASCII_DISCLAIMER) + }) + @ApiResponses(value = { + @ApiResponse(code = HttpStatus.OK_200, message = "OK", response = List.class), + @ApiResponse(code = HttpStatus.BAD_REQUEST_400, message = FORWARD_BASE_ADDRESS + " or forward structure format is not valid"), + @ApiResponse(code = HttpStatus.NOT_FOUND_404, message = "requested base forward address does not match a user"), + @ApiResponse(code = HttpStatus.INTERNAL_SERVER_ERROR_500, + message = "Internal server error - Something went bad on the server side.") + }) + public HaltException addToForwardDestinations(Request request, Response response) throws JsonExtractException, AddressException, RecipientRewriteTableException, UsersRepositoryException, DomainListException { + MailAddress forwardBaseAddress = parseMailAddress(request.params(FORWARD_BASE_ADDRESS)); + ensureUserExist(forwardBaseAddress); + MailAddress destinationAddress = parseMailAddress(request.params(FORWARD_DESTINATION_ADDRESS)); + recipientRewriteTable.addForwardMapping(forwardBaseAddress.getLocalPart(), forwardBaseAddress.getDomain(), destinationAddress.asString()); + return halt(HttpStatus.CREATED_201); + } + + private void ensureUserExist(MailAddress mailAddress) throws UsersRepositoryException { + if (!usersRepository.contains(mailAddress.asString())) { + throw ErrorResponder.builder() + .statusCode(HttpStatus.NOT_FOUND_404) + .type(ErrorType.INVALID_ARGUMENT) + .message("Requested base forward address does not correspond to a user") + .haltError(); + } + } + + + @DELETE + @Path(ROOT_PATH + "/{" + FORWARD_BASE_ADDRESS + "}/targets/{" + FORWARD_DESTINATION_ADDRESS + "}") + @ApiOperation(value = "remove a destination address from a forward") + @ApiImplicitParams({ + @ApiImplicitParam(required = true, dataType = "string", name = FORWARD_BASE_ADDRESS, paramType = "path"), + @ApiImplicitParam(required = true, dataType = "string", name = FORWARD_DESTINATION_ADDRESS, paramType = "path") + }) + @ApiResponses(value = { + @ApiResponse(code = HttpStatus.OK_200, message = "OK", response = List.class), + @ApiResponse(code = HttpStatus.BAD_REQUEST_400, + message = FORWARD_BASE_ADDRESS + " or forward structure format is not valid"), + @ApiResponse(code = HttpStatus.INTERNAL_SERVER_ERROR_500, + message = "Internal server error - Something went bad on the server side.") + }) + public HaltException removeFromForwardDestination(Request request, Response response) throws JsonExtractException, AddressException, RecipientRewriteTableException { + MailAddress baseAddress = parseMailAddress(request.params(FORWARD_BASE_ADDRESS)); + MailAddress destinationAddressToBeRemoved = parseMailAddress(request.params(FORWARD_DESTINATION_ADDRESS)); + recipientRewriteTable.removeForwardMapping( + baseAddress.getLocalPart(), + baseAddress.getDomain(), + destinationAddressToBeRemoved.asString()); + return halt(HttpStatus.OK_200); + } + + @GET + @Path(ROOT_PATH + "/{" + FORWARD_BASE_ADDRESS + "}") + @ApiOperation(value = "listing forward destinations") + @ApiImplicitParams({ + @ApiImplicitParam(required = true, dataType = "string", name = FORWARD_BASE_ADDRESS, paramType = "path") + }) + @ApiResponses(value = { + @ApiResponse(code = HttpStatus.OK_200, message = "OK", response = List.class), + @ApiResponse(code = HttpStatus.BAD_REQUEST_400, message = "The forward is not an address"), + @ApiResponse(code = HttpStatus.NOT_FOUND_404, message = "The forward does not exist"), + @ApiResponse(code = HttpStatus.INTERNAL_SERVER_ERROR_500, + message = "Internal server error - Something went bad on the server side.") + }) + public ImmutableSet<ForwardDestinationResponse> listForwardDestinations(Request request, Response response) throws RecipientRewriteTable.ErrorMappingException, RecipientRewriteTableException { + MailAddress baseAddress = parseMailAddress(request.params(FORWARD_BASE_ADDRESS)); + Mappings mappings = recipientRewriteTable.getMappings(baseAddress.getLocalPart(), baseAddress.getDomain()); + + ensureNonEmptyMappings(mappings); + + return mappings.select(Mapping.Type.Forward) + .asStream() + .map(mapping -> mapping.asMailAddress() + .orElseThrow(() -> new IllegalStateException(String.format("Can not compute address for mapping %s", mapping.asString())))) + .map(MailAddress::asString) + .sorted() + .map(ForwardDestinationResponse::new) + .collect(Guavate.toImmutableSet()); + } + + private MailAddress parseMailAddress(String address) { + try { + String decodedAddress = URLDecoder.decode(address, StandardCharsets.UTF_8.displayName()); + return new MailAddress(decodedAddress); + } catch (AddressException e) { + throw ErrorResponder.builder() + .statusCode(HttpStatus.BAD_REQUEST_400) + .type(ErrorType.INVALID_ARGUMENT) + .message("The forward is not an email address") + .cause(e) + .haltError(); + } catch (UnsupportedEncodingException e) { + LOGGER.error("UTF-8 should be a valid encoding"); + throw ErrorResponder.builder() + .statusCode(HttpStatus.INTERNAL_SERVER_ERROR_500) + .type(ErrorType.SERVER_ERROR) + .message("Internal server error - Something went bad on the server side.") + .cause(e) + .haltError(); + } + } + + private void ensureNonEmptyMappings(Mappings mappings) { + if (mappings == null || mappings.isEmpty()) { + throw ErrorResponder.builder() + .statusCode(HttpStatus.NOT_FOUND_404) + .type(ErrorType.INVALID_ARGUMENT) + .message("The forward does not exist") + .haltError(); + } + } +} http://git-wip-us.apache.org/repos/asf/james-project/blob/92f4a682/server/protocols/webadmin/webadmin-data/src/test/java/org/apache/james/webadmin/routes/ForwardRoutesTest.java ---------------------------------------------------------------------- diff --git a/server/protocols/webadmin/webadmin-data/src/test/java/org/apache/james/webadmin/routes/ForwardRoutesTest.java b/server/protocols/webadmin/webadmin-data/src/test/java/org/apache/james/webadmin/routes/ForwardRoutesTest.java new file mode 100644 index 0000000..11a1c10 --- /dev/null +++ b/server/protocols/webadmin/webadmin-data/src/test/java/org/apache/james/webadmin/routes/ForwardRoutesTest.java @@ -0,0 +1,676 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.webadmin.routes; + +import static com.jayway.restassured.RestAssured.when; +import static com.jayway.restassured.RestAssured.with; +import static org.apache.james.webadmin.Constants.SEPARATOR; +import static org.apache.james.webadmin.WebAdminServer.NO_CONFIGURATION; +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.hasItems; +import static org.hamcrest.CoreMatchers.is; +import static org.mockito.Matchers.any; +import static org.mockito.Matchers.anyString; +import static org.mockito.Matchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; + +import java.util.List; +import java.util.Map; + +import org.apache.commons.configuration.DefaultConfigurationBuilder; +import org.apache.james.core.Domain; +import org.apache.james.dnsservice.api.DNSService; +import org.apache.james.domainlist.api.DomainList; +import org.apache.james.domainlist.memory.MemoryDomainList; +import org.apache.james.metrics.logger.DefaultMetricFactory; +import org.apache.james.rrt.api.RecipientRewriteTable; +import org.apache.james.rrt.api.RecipientRewriteTableException; +import org.apache.james.rrt.memory.MemoryRecipientRewriteTable; +import org.apache.james.user.api.UsersRepository; +import org.apache.james.user.memory.MemoryUsersRepository; +import org.apache.james.webadmin.WebAdminServer; +import org.apache.james.webadmin.WebAdminUtils; +import org.apache.james.webadmin.utils.JsonTransformer; +import org.eclipse.jetty.http.HttpStatus; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import com.jayway.restassured.RestAssured; +import com.jayway.restassured.filter.log.LogDetail; +import com.jayway.restassured.http.ContentType; + +class ForwardRoutesTest { + + private static final Domain DOMAIN = Domain.of("b.com"); + public static final String CEDRIC = "cedric@" + DOMAIN.name(); + public static final String ALICE = "alice@" + DOMAIN.name(); + public static final String ALICE_WITH_SLASH = "alice/@" + DOMAIN.name(); + public static final String ALICE_WITH_ENCODED_SLASH = "alice%2F@" + DOMAIN.name(); + public static final String BOB = "bob@" + DOMAIN.name(); + public static final String BOB_PASSWORD = "123456"; + public static final String ALICE_PASSWORD = "789123"; + public static final String ALICE_SLASH_PASSWORD = "abcdef"; + public static final String CEDRIC_PASSWORD = "456789"; + + private WebAdminServer webAdminServer; + + private void createServer(ForwardRoutes forwardRoutes) throws Exception { + webAdminServer = WebAdminUtils.createWebAdminServer( + new DefaultMetricFactory(), + forwardRoutes); + webAdminServer.configure(NO_CONFIGURATION); + webAdminServer.await(); + + RestAssured.requestSpecification = WebAdminUtils.buildRequestSpecification(webAdminServer) + .setBasePath("address/forwards") + .log(LogDetail.METHOD) + .build(); + } + + @AfterEach + void stop() { + webAdminServer.destroy(); + } + + @Nested + class NormalBehaviour { + + MemoryUsersRepository usersRepository; + MemoryDomainList domainList; + MemoryRecipientRewriteTable memoryRecipientRewriteTable; + + @BeforeEach + void setUp() throws Exception { + memoryRecipientRewriteTable = new MemoryRecipientRewriteTable(); + DNSService dnsService = mock(DNSService.class); + domainList = new MemoryDomainList(dnsService); + domainList.setAutoDetectIP(false); + domainList.setAutoDetect(false); + domainList.configure(new DefaultConfigurationBuilder()); + domainList.addDomain(DOMAIN); + + usersRepository = MemoryUsersRepository.withVirtualHosting(); + usersRepository.setDomainList(domainList); + usersRepository.configure(new DefaultConfigurationBuilder()); + + usersRepository.addUser(BOB, BOB_PASSWORD); + usersRepository.addUser(ALICE, ALICE_PASSWORD); + usersRepository.addUser(ALICE_WITH_SLASH, ALICE_SLASH_PASSWORD); + usersRepository.addUser(CEDRIC, CEDRIC_PASSWORD); + + createServer(new ForwardRoutes(memoryRecipientRewriteTable, usersRepository, new JsonTransformer())); + } + + @Test + void getForwardShouldBeEmpty() { + when() + .get() + .then() + .contentType(ContentType.JSON) + .statusCode(HttpStatus.OK_200) + .body(is("[]")); + } + + @Test + void getForwardShouldListExistingForwardsInAlphabeticOrder() { + with() + .put(ALICE + SEPARATOR + "targets" + SEPARATOR + BOB); + + with() + .put(CEDRIC + SEPARATOR + "targets" + SEPARATOR + BOB); + + List<String> addresses = + when() + .get() + .then() + .contentType(ContentType.JSON) + .statusCode(HttpStatus.OK_200) + .extract() + .body() + .jsonPath() + .getList("."); + assertThat(addresses).containsExactly(ALICE, CEDRIC); + } + + @Test + void getNotRegisteredForwardShouldReturnNotFound() { + Map<String, Object> errors = when() + .get("unknown@domain.travel") + .then() + .statusCode(HttpStatus.NOT_FOUND_404) + .contentType(ContentType.JSON) + .extract() + .body() + .jsonPath() + .getMap("."); + + assertThat(errors) + .containsEntry("statusCode", HttpStatus.NOT_FOUND_404) + .containsEntry("type", "InvalidArgument") + .containsEntry("message", "The forward does not exist"); + } + + @Test + void putUserInForwardShouldReturnCreated() { + when() + .put(ALICE + SEPARATOR + "targets" + SEPARATOR + BOB) + .then() + .statusCode(HttpStatus.CREATED_201); + } + + @Test + void putUserWithSlashInForwardShouldReturnCreated() { + when() + .put(BOB + SEPARATOR + "targets" + SEPARATOR + ALICE_WITH_ENCODED_SLASH) + .then() + .statusCode(HttpStatus.CREATED_201); + } + + @Test + void putUserWithSlashInForwardShouldAddItAsADestination() { + with() + .put(BOB + SEPARATOR + "targets" + SEPARATOR + ALICE_WITH_ENCODED_SLASH); + + when() + .get(BOB) + .then() + .contentType(ContentType.JSON) + .statusCode(HttpStatus.OK_200) + .body("mailAddress", hasItems(ALICE_WITH_SLASH)); + } + + @Test + void putUserInForwardShouldCreateForward() { + with() + .put(ALICE + SEPARATOR + "targets" + SEPARATOR + BOB); + + when() + .get(ALICE) + .then() + .contentType(ContentType.JSON) + .statusCode(HttpStatus.OK_200) + .body("mailAddress", hasItems(BOB)); + } + + @Test + void putUserInForwardWithEncodedSlashShouldReturnCreated() { + when() + .put(ALICE_WITH_ENCODED_SLASH + SEPARATOR + "targets" + SEPARATOR + BOB) + .then() + .statusCode(HttpStatus.CREATED_201); + } + + @Test + void putUserInForwardWithEncodedSlashShouldCreateForward() { + with() + .put(ALICE_WITH_ENCODED_SLASH + SEPARATOR + "targets" + SEPARATOR + BOB); + + when() + .get(ALICE_WITH_ENCODED_SLASH) + .then() + .contentType(ContentType.JSON) + .statusCode(HttpStatus.OK_200) + .body("mailAddress", hasItems(BOB)); + } + + @Test + void putSameUserInForwardTwiceShouldBeIdempotent() { + with() + .put(ALICE + SEPARATOR + "targets" + SEPARATOR + BOB); + + with() + .put(ALICE + SEPARATOR + "targets" + SEPARATOR + BOB); + + when() + .get(ALICE) + .then() + .contentType(ContentType.JSON) + .statusCode(HttpStatus.OK_200) + .body("mailAddress", hasItems(BOB)); + } + + @Test + void putUserInForwardShouldAllowSeveralDestinations() { + with() + .put(ALICE + SEPARATOR + "targets" + SEPARATOR + BOB); + + with() + .put(ALICE + SEPARATOR + "targets" + SEPARATOR + CEDRIC); + + when() + .get(ALICE) + .then() + .contentType(ContentType.JSON) + .statusCode(HttpStatus.OK_200) + .body("mailAddress", hasItems(BOB, CEDRIC)); + } + + @Test + void forwardShouldAllowIdentity() { + with() + .put(ALICE + SEPARATOR + "targets" + SEPARATOR + ALICE); + + with() + .put(ALICE + SEPARATOR + "targets" + SEPARATOR + CEDRIC); + + when() + .get(ALICE) + .then() + .contentType(ContentType.JSON) + .statusCode(HttpStatus.OK_200) + .body("mailAddress", hasItems(ALICE, CEDRIC)); + } + + @Test + void putUserInForwardShouldRequireExistingBaseUser() { + Map<String, Object> errors = when() + .put("notFound@" + DOMAIN.name() + SEPARATOR + "targets" + SEPARATOR + BOB) + .then() + .statusCode(HttpStatus.NOT_FOUND_404) + .contentType(ContentType.JSON) + .extract() + .body() + .jsonPath() + .getMap("."); + + assertThat(errors) + .containsEntry("statusCode", HttpStatus.NOT_FOUND_404) + .containsEntry("type", "InvalidArgument") + .containsEntry("message", "Requested base forward address does not correspond to a user"); + } + + @Test + void getForwardShouldReturnMembersInAlphabeticOrder() { + with() + .put(ALICE + SEPARATOR + "targets" + SEPARATOR + BOB); + + with() + .put(ALICE + SEPARATOR + "targets" + SEPARATOR + CEDRIC); + + when() + .get(ALICE) + .then() + .contentType(ContentType.JSON) + .statusCode(HttpStatus.OK_200) + .body("mailAddress", hasItems(BOB, CEDRIC)); + } + + @Test + void forwardShouldAcceptExternalAddresses() { + String externalAddress = "exter...@other.com"; + + with() + .put(ALICE + SEPARATOR + "targets" + SEPARATOR + externalAddress); + + when() + .get(ALICE) + .then() + .contentType(ContentType.JSON) + .statusCode(HttpStatus.OK_200) + .body("mailAddress", hasItems(externalAddress)); + } + + @Test + void deleteUserNotInForwardShouldReturnOK() { + when() + .delete(ALICE + SEPARATOR + "targets" + SEPARATOR + BOB) + .then() + .statusCode(HttpStatus.OK_200); + } + + @Test + void deleteLastUserInForwardShouldDeleteForward() { + with() + .put(ALICE + SEPARATOR + "targets" + SEPARATOR + BOB); + + with() + .delete(ALICE + SEPARATOR + "targets" + SEPARATOR + BOB); + + when() + .get() + .then() + .contentType(ContentType.JSON) + .statusCode(HttpStatus.OK_200) + .body(is("[]")); + } + } + + @Nested + class FilteringOtherRewriteRuleTypes extends NormalBehaviour { + + @BeforeEach + void setup() throws Exception { + super.setUp(); + memoryRecipientRewriteTable.addErrorMapping("error", DOMAIN, "disabled"); + memoryRecipientRewriteTable.addRegexMapping("regex", DOMAIN, ".*@b\\.com"); + memoryRecipientRewriteTable.addAliasDomainMapping(Domain.of("alias"), DOMAIN); + } + + } + + @Nested + class ExceptionHandling { + + private RecipientRewriteTable memoryRecipientRewriteTable; + + @BeforeEach + void setUp() throws Exception { + memoryRecipientRewriteTable = mock(RecipientRewriteTable.class); + UsersRepository userRepository = mock(UsersRepository.class); + Mockito.when(userRepository.contains(eq(ALICE))).thenReturn(true); + DomainList domainList = mock(DomainList.class); + Mockito.when(domainList.containsDomain(any())).thenReturn(true); + createServer(new ForwardRoutes(memoryRecipientRewriteTable, userRepository, new JsonTransformer())); + } + + @Test + void getMalformedForwardShouldReturnBadRequest() { + Map<String, Object> errors = when() + .get("not-an-address") + .then() + .statusCode(HttpStatus.BAD_REQUEST_400) + .contentType(ContentType.JSON) + .extract() + .body() + .jsonPath() + .getMap("."); + + assertThat(errors) + .containsEntry("statusCode", HttpStatus.BAD_REQUEST_400) + .containsEntry("type", "InvalidArgument") + .containsEntry("message", "The forward is not an email address") + .containsEntry("cause", "Out of data at position 1 in 'not-an-address'"); + } + + @Test + void putMalformedForwardShouldReturnBadRequest() { + Map<String, Object> errors = when() + .put("not-an-address" + SEPARATOR + "targets" + SEPARATOR + BOB) + .then() + .statusCode(HttpStatus.BAD_REQUEST_400) + .contentType(ContentType.JSON) + .extract() + .body() + .jsonPath() + .getMap("."); + + assertThat(errors) + .containsEntry("statusCode", HttpStatus.BAD_REQUEST_400) + .containsEntry("type", "InvalidArgument") + .containsEntry("message", "The forward is not an email address") + .containsEntry("cause", "Out of data at position 1 in 'not-an-address'"); + } + + @Test + void putUserInForwardWithSlashShouldReturnNotFound() { + when() + .put(ALICE_WITH_SLASH + SEPARATOR + "targets" + SEPARATOR + BOB) + .then() + .statusCode(HttpStatus.NOT_FOUND_404) + .body(containsString("404 Not found")); + } + + @Test + void putUserWithSlashInForwardShouldReturnNotFound() { + when() + .put(ALICE + SEPARATOR + "targets" + SEPARATOR + ALICE_WITH_SLASH) + .then() + .statusCode(HttpStatus.NOT_FOUND_404) + .body(containsString("404 Not found")); + } + + @Test + void putMalformedAddressShouldReturnBadRequest() { + Map<String, Object> errors = when() + .put(ALICE + SEPARATOR + "targets" + SEPARATOR + "not-an-address") + .then() + .statusCode(HttpStatus.BAD_REQUEST_400) + .contentType(ContentType.JSON) + .extract() + .body() + .jsonPath() + .getMap("."); + + assertThat(errors) + .containsEntry("statusCode", HttpStatus.BAD_REQUEST_400) + .containsEntry("type", "InvalidArgument") + .containsEntry("message", "The forward is not an email address") + .containsEntry("cause", "Out of data at position 1 in 'not-an-address'"); + } + + @Test + void putRequiresTwoPathParams() { + when() + .put(ALICE) + .then() + .statusCode(HttpStatus.BAD_REQUEST_400) + .body(is("")); + } + + @Test + void deleteMalformedForwardShouldReturnBadRequest() { + Map<String, Object> errors = when() + .delete("not-an-address" + SEPARATOR + "targets" + SEPARATOR + ALICE) + .then() + .statusCode(HttpStatus.BAD_REQUEST_400) + .contentType(ContentType.JSON) + .extract() + .body() + .jsonPath() + .getMap("."); + + assertThat(errors) + .containsEntry("statusCode", HttpStatus.BAD_REQUEST_400) + .containsEntry("type", "InvalidArgument") + .containsEntry("message", "The forward is not an email address") + .containsEntry("cause", "Out of data at position 1 in 'not-an-address'"); + } + + @Test + void deleteMalformedAddressShouldReturnBadRequest() { + Map<String, Object> errors = when() + .delete(ALICE + SEPARATOR + "targets" + SEPARATOR + "not-an-address") + .then() + .statusCode(HttpStatus.BAD_REQUEST_400) + .contentType(ContentType.JSON) + .extract() + .body() + .jsonPath() + .getMap("."); + + assertThat(errors) + .containsEntry("statusCode", HttpStatus.BAD_REQUEST_400) + .containsEntry("type", "InvalidArgument") + .containsEntry("message", "The forward is not an email address") + .containsEntry("cause", "Out of data at position 1 in 'not-an-address'"); + } + + @Test + void deleteRequiresTwoPathParams() { + when() + .delete(ALICE) + .then() + .statusCode(HttpStatus.BAD_REQUEST_400) + .body(is("")); + } + + @Test + void putShouldReturnErrorWhenRecipientRewriteTableExceptionIsThrown() throws Exception { + doThrow(RecipientRewriteTableException.class) + .when(memoryRecipientRewriteTable) + .addForwardMapping(anyString(), any(), anyString()); + + when() + .put(ALICE + SEPARATOR + "targets" + SEPARATOR + BOB) + .then() + .statusCode(HttpStatus.INTERNAL_SERVER_ERROR_500) + .body(containsString("500 Internal Server Error")); + } + + @Test + void putShouldReturnErrorWhenErrorMappingExceptionIsThrown() throws Exception { + doThrow(RecipientRewriteTable.ErrorMappingException.class) + .when(memoryRecipientRewriteTable) + .addForwardMapping(anyString(), any(), anyString()); + + when() + .put(ALICE + SEPARATOR + "targets" + SEPARATOR + BOB) + .then() + .statusCode(HttpStatus.INTERNAL_SERVER_ERROR_500) + .body(containsString("500 Internal Server Error")); + } + + @Test + void putShouldReturnErrorWhenRuntimeExceptionIsThrown() throws Exception { + doThrow(RuntimeException.class) + .when(memoryRecipientRewriteTable) + .addForwardMapping(anyString(), any(), anyString()); + + when() + .put(ALICE + SEPARATOR + "targets" + SEPARATOR + BOB) + .then() + .statusCode(HttpStatus.INTERNAL_SERVER_ERROR_500) + .body(containsString("500 Internal Server Error")); + } + + @Test + void getAllShouldReturnErrorWhenRecipientRewriteTableExceptionIsThrown() throws Exception { + doThrow(RecipientRewriteTableException.class) + .when(memoryRecipientRewriteTable) + .getAllMappings(); + + when() + .get() + .then() + .statusCode(HttpStatus.INTERNAL_SERVER_ERROR_500) + .body(containsString("500 Internal Server Error")); + } + + @Test + void getAllShouldReturnErrorWhenErrorMappingExceptionIsThrown() throws Exception { + doThrow(RecipientRewriteTable.ErrorMappingException.class) + .when(memoryRecipientRewriteTable) + .getAllMappings(); + + when() + .get() + .then() + .statusCode(HttpStatus.INTERNAL_SERVER_ERROR_500) + .body(containsString("500 Internal Server Error")); + } + + @Test + void getAllShouldReturnErrorWhenRuntimeExceptionIsThrown() throws Exception { + doThrow(RuntimeException.class) + .when(memoryRecipientRewriteTable) + .getAllMappings(); + + when() + .get() + .then() + .statusCode(HttpStatus.INTERNAL_SERVER_ERROR_500) + .body(containsString("500 Internal Server Error")); + } + + @Test + void deleteShouldReturnErrorWhenRecipientRewriteTableExceptionIsThrown() throws Exception { + doThrow(RecipientRewriteTableException.class) + .when(memoryRecipientRewriteTable) + .removeForwardMapping(anyString(), any(), anyString()); + + when() + .delete(ALICE + SEPARATOR + "targets" + SEPARATOR + BOB) + .then() + .statusCode(HttpStatus.INTERNAL_SERVER_ERROR_500) + .body(containsString("500 Internal Server Error")); + } + + @Test + void deleteShouldReturnErrorWhenErrorMappingExceptionIsThrown() throws Exception { + doThrow(RecipientRewriteTable.ErrorMappingException.class) + .when(memoryRecipientRewriteTable) + .removeForwardMapping(anyString(), any(), anyString()); + + when() + .delete(ALICE + SEPARATOR + "targets" + SEPARATOR + BOB) + .then() + .statusCode(HttpStatus.INTERNAL_SERVER_ERROR_500) + .body(containsString("500 Internal Server Error")); + } + + @Test + void deleteShouldReturnErrorWhenRuntimeExceptionIsThrown() throws Exception { + doThrow(RuntimeException.class) + .when(memoryRecipientRewriteTable) + .removeForwardMapping(anyString(), any(), anyString()); + + when() + .delete(ALICE + SEPARATOR + "targets" + SEPARATOR + BOB) + .then() + .statusCode(HttpStatus.INTERNAL_SERVER_ERROR_500) + .body(containsString("500 Internal Server Error")); + } + + @Test + void getShouldReturnErrorWhenRecipientRewriteTableExceptionIsThrown() throws Exception { + doThrow(RecipientRewriteTableException.class) + .when(memoryRecipientRewriteTable) + .getMappings(anyString(), any()); + + when() + .get(ALICE) + .then() + .statusCode(HttpStatus.INTERNAL_SERVER_ERROR_500) + .body(containsString("500 Internal Server Error")); + } + + @Test + void getShouldReturnErrorWhenErrorMappingExceptionIsThrown() throws Exception { + doThrow(RecipientRewriteTable.ErrorMappingException.class) + .when(memoryRecipientRewriteTable) + .getMappings(anyString(), any()); + + when() + .get(ALICE) + .then() + .statusCode(HttpStatus.INTERNAL_SERVER_ERROR_500) + .body(containsString("500 Internal Server Error")); + } + + @Test + void getShouldReturnErrorWhenRuntimeExceptionIsThrown() throws Exception { + doThrow(RuntimeException.class) + .when(memoryRecipientRewriteTable) + .getMappings(anyString(), any()); + + when() + .get(ALICE) + .then() + .statusCode(HttpStatus.INTERNAL_SERVER_ERROR_500) + .body(containsString("500 Internal Server Error")); + } + } + +} --------------------------------------------------------------------- To unsubscribe, e-mail: server-dev-unsubscr...@james.apache.org For additional commands, e-mail: server-dev-h...@james.apache.org