diff --git a/docs/README.md b/docs/README.md index 3e60ffe..e30e9a2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,60 +2,51 @@ This messaging microservice is intended for use with [uportal-home](https://github.com/uPortal-Project/uportal-home). -uPortal messages include both notifications and announcements, and can be tailored for audiences as small as one person. +uPortal messages include both notifications and announcements, and can be tailored for audiences as small as one person. ## What is a message? -A message is an announcement or notification which can be targeted to an entire user population, a subset of the population, or a single user. +A message is an announcement or notification which can be targeted to an entire user population, a subset of the population, or a single user. This microservice processes messages in json form. [Details of json format are here](json.md) ## Configuration -Set the source of your messages in the ``application.properties`` file. In this example, we've selected a json file in our resources directory. +Set the source of your messages in the ``application.properties`` file. In this example, we've selected a json file in our resources directory. ``` javascript message.source=classpath:messages.json ``` ## To Build -This project uses maven. ```$ mvn package ``` will build a warfile for deployment. +This project uses maven. ```$ mvn package ``` will build a warfile for deployment. -To run locally, ```$ mvn spring-boot:run ``` will compile and run this microservice. +To run locally, ```$ mvn spring-boot:run ``` will compile and run this microservice. ## Endpoints +### `/` +Implemented in `MessagesController`. -### {*/*} - -calls: -```java - @RequestMapping("/") - public @ResponseBody - void index(HttpServletResponse response) -``` -returns: +Responds: ``` {"status":"up"} ``` -description: -This endpoint returns a small json object indicating that the status of the application is healthy. -### {*/messages*} +Description: +This endpoint returns a small json object indicating that the status of the application is healthy. -calls: -``` java - @RequestMapping(value="/messages", method=RequestMethod.GET) - public @ResponseBody void messages(HttpServletRequest request, - HttpServletResponse response) -``` -returns: +### `/messages` + +Implemented in `MessagesController`. + +Responds: A JSON object containing messages filtered to the viewing user and the current context. description: -Intended as the primary endpoint for servicing typical users. The idea is to move all the complication of message +Intended as the primary endpoint for servicing typical users. The idea is to move all the complication of message resolution server-side into this microservice so that a typical client can request this data and uncritically render it. Currently filters to: @@ -66,34 +57,59 @@ specified in ISO format, as in `2018-02-14` or `2018-02-14:09:46:00`), AND Expectations: -+ `isMemberOf` header conveying semicolon-delimited group memberships. (This practice is typical in UW-Madison -Shibboleth SP implementation.) Fails gracefully yet safely: if this header is not present, considers the user a member ++ `isMemberOf` header conveying semicolon-delimited group memberships. (This practice is typical in UW-Madison +Shibboleth SP implementation.) Fails gracefully yet safely: if this header is not present, considers the user a member of no groups. Versioning: The details of the filtering are NOT a semantically versioned aspect of the API. That is to say, what is versioned -here is that `/messages` returns the messages appropriate for the requesting user. Increasing sophistication in what +here is that `/messages` returns the messages appropriate for the requesting user. Increasing sophistication in what "appropriate" means is not a breaking change. Security: -WARNING: Does not apply any access control other than filtering to messages applicable to the user's groups. If -additional access control is needed (it may not be needed), implement it at the container layer. - -### {*/allMessages*} -calls: -``` java - @RequestMapping(value = "/allMessages", method = RequestMethod.GET) - public @ResponseBody - void messages(HttpServletRequest request, - HttpServletResponse response) -``` +WARNING: Does not apply any access control other than filtering to messages applicable to the user's groups. If +additional access control is needed (it may not be needed), implement it at the container layer. -returns: -A JSON object, containing every known message, regardless of all criteria. +### `/message/{id}` -description: +Implemented in `MessagesController`. + +Responds: + ++ A JSON representation of the message with the given `id`, or ++ `404 NOT FOUND` if no message with requested `id`, or ++ `403 FORBIDDEN` if message exists but is expired, premature, or the requesting user is not in its audience. + +Description: +Intended as view on a specific message. + +### `/admin/allMessages` + +Implemented in `MessagesController`. + +Responds: +A JSON object, containing every known message, regardless of all criteria. + +Description: Intended as an administrative or troubleshooting view on the data. Security: WARNING: Does not apply any access control. Implement access control at the container layer. Whatever access control -is appropriate, apply it to the `/allMessages` path at e.g. the `httpd` layer. +is appropriate, apply it to the `/admin/allMessages` path at e.g. the `httpd` layer. The `/admin` prefix is intended to facilitate this. + +### `/admin/message/{id}` + +Implemented in `MessagesController`. + +Responds: + ++ A JSON representation of the message with the given `id`, or ++ 404 NOT FOUND if no message with requested `id` + +Description: +Intended as an administrative or troubleshooting view on the data for a specific message. + +Security: +WARNING: Does not apply any access control. Implement access control at the container layer. +Whatever access control is appropriate, apply it to the `/admin/message` path at e.g. the `httpd` +layer. The `/admin` prefix is intended to facilitate this. diff --git a/src/main/java/edu/wisc/my/messages/controller/MessagesController.java b/src/main/java/edu/wisc/my/messages/controller/MessagesController.java index 551a975..66a13a6 100644 --- a/src/main/java/edu/wisc/my/messages/controller/MessagesController.java +++ b/src/main/java/edu/wisc/my/messages/controller/MessagesController.java @@ -1,5 +1,10 @@ package edu.wisc.my.messages.controller; +import edu.wisc.my.messages.exception.ExpiredMessageException; +import edu.wisc.my.messages.exception.MessageNotFoundException; +import edu.wisc.my.messages.exception.PrematureMessageException; +import edu.wisc.my.messages.exception.UserNotInMessageAudienceException; +import edu.wisc.my.messages.model.Message; import edu.wisc.my.messages.model.User; import edu.wisc.my.messages.service.MessagesService; import java.util.HashMap; @@ -10,6 +15,7 @@ import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -50,7 +56,12 @@ public Map messages(HttpServletRequest request) { return responseMap; } - @GetMapping("/allMessages") + /** + * Get all the messages in the system, regardless of audience, dates, etc. + * + * @return Map where key "messages" has value List of all Messages. + */ + @GetMapping("/admin/allMessages") public Map allMessages() { Map responseMap = new HashMap(); responseMap.put("messages", messagesService.allMessages()); @@ -65,6 +76,50 @@ public Map index() { return statusResponse; } + /** + * Get a specific message regardless of the message's audience, dates, etc. + * + * @param id message ID to match + * @return Message with matching ID + */ + @RequestMapping("/admin/message/{id}") + public Message adminMessageById(@PathVariable String id) throws MessageNotFoundException { + + Message message = messagesService.messageById(id); + + if (null == message) { + throw new MessageNotFoundException(); + } + return message; + } + + /** + * Get a specific message, limited by the requesting user's context. + * + * @throws PrematureMessageException if the message is not yet gone live + * @throws ExpiredMessageException if the message is expired + * @throws UserNotInMessageAudienceException if the requesting user is not in the audience + * @returns the requested message, or null if none matching + */ + @RequestMapping("/message/{id}") + public Message messageById(@PathVariable String id, HttpServletRequest request) + throws UserNotInMessageAudienceException, PrematureMessageException, ExpiredMessageException, MessageNotFoundException { + + String isMemberOfHeader = request.getHeader("isMemberOf"); + Set groups = + isMemberOfHeaderParser.groupsFromHeaderValue(isMemberOfHeader); + User user = new User(); + user.setGroups(groups); + + Message message = messagesService.messageByIdForUser(id, user); + + if (null == message) { + throw new MessageNotFoundException(); + } + + return message; + } + @Autowired public void setMessagesService(MessagesService messagesService) { this.messagesService = messagesService; @@ -75,5 +130,4 @@ public void setIsMemberOfHeaderParser( IsMemberOfHeaderParser isMemberOfHeaderParser) { this.isMemberOfHeaderParser = isMemberOfHeaderParser; } - } diff --git a/src/main/java/edu/wisc/my/messages/exception/ExpiredMessageException.java b/src/main/java/edu/wisc/my/messages/exception/ExpiredMessageException.java new file mode 100644 index 0000000..abb09f3 --- /dev/null +++ b/src/main/java/edu/wisc/my/messages/exception/ExpiredMessageException.java @@ -0,0 +1,29 @@ +package edu.wisc.my.messages.exception; + +import edu.wisc.my.messages.model.Message; +import java.time.LocalDateTime; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(value = HttpStatus.FORBIDDEN, reason = "Requested message is expired.") +public class ExpiredMessageException + extends ForbiddenMessageException { + + private final Message expiredMessage; + + /** + * Time of consideration. The frame of reference for considering the message expired. Typically, + * this is "now". + */ + private final LocalDateTime asOfWhen; + + public ExpiredMessageException(Message messageWithRequestedId, LocalDateTime asOfWhen) { + super("Message " + + ((messageWithRequestedId == null) ? + "" : + (" with id [" + messageWithRequestedId.getId() + "] ")) + + "is expired as of " + asOfWhen); + this.expiredMessage = messageWithRequestedId; + this.asOfWhen = asOfWhen; + } +} diff --git a/src/main/java/edu/wisc/my/messages/exception/ForbiddenMessageException.java b/src/main/java/edu/wisc/my/messages/exception/ForbiddenMessageException.java new file mode 100644 index 0000000..5ba120e --- /dev/null +++ b/src/main/java/edu/wisc/my/messages/exception/ForbiddenMessageException.java @@ -0,0 +1,17 @@ +package edu.wisc.my.messages.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +/** + * Message representing that access to a requested message is forbidden. Sub-classes represent + * specific reasons why access might be forbidden. + */ +@ResponseStatus(value = HttpStatus.FORBIDDEN, reason = "Access denied to requested message.") +public class ForbiddenMessageException + extends Exception { + + public ForbiddenMessageException(String message) { + super(message); + } +} diff --git a/src/main/java/edu/wisc/my/messages/exception/MessageNotFoundException.java b/src/main/java/edu/wisc/my/messages/exception/MessageNotFoundException.java new file mode 100644 index 0000000..d2400eb --- /dev/null +++ b/src/main/java/edu/wisc/my/messages/exception/MessageNotFoundException.java @@ -0,0 +1,13 @@ +package edu.wisc.my.messages.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +/** + * Represents case where requested message was not found. + */ +@ResponseStatus(value = HttpStatus.NOT_FOUND, reason = "Did not find requested message.") +public class MessageNotFoundException + extends Exception { + +} diff --git a/src/main/java/edu/wisc/my/messages/exception/PrematureMessageException.java b/src/main/java/edu/wisc/my/messages/exception/PrematureMessageException.java new file mode 100644 index 0000000..791bce4 --- /dev/null +++ b/src/main/java/edu/wisc/my/messages/exception/PrematureMessageException.java @@ -0,0 +1,27 @@ +package edu.wisc.my.messages.exception; + +import edu.wisc.my.messages.model.Message; +import java.time.LocalDateTime; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +/** + * Thrown on un-privileged request for a message that has not yet gone live. + */ +@ResponseStatus(value = HttpStatus.FORBIDDEN, reason = "Requested message is premature.") +public class PrematureMessageException + extends ForbiddenMessageException { + + private Message prematureMessage; + private LocalDateTime asOfWhen; + + public PrematureMessageException(Message prematureMessage, LocalDateTime asOfWhen) { + super("Message with id " + + ((null == prematureMessage) ? + "" : + prematureMessage.getId() + " ") + + "is premature as of " + asOfWhen); + this.prematureMessage = prematureMessage; + this.asOfWhen = asOfWhen; + } +} diff --git a/src/main/java/edu/wisc/my/messages/exception/UserNotInMessageAudienceException.java b/src/main/java/edu/wisc/my/messages/exception/UserNotInMessageAudienceException.java new file mode 100644 index 0000000..57b0be7 --- /dev/null +++ b/src/main/java/edu/wisc/my/messages/exception/UserNotInMessageAudienceException.java @@ -0,0 +1,19 @@ +package edu.wisc.my.messages.exception; + +import edu.wisc.my.messages.model.Message; +import edu.wisc.my.messages.model.User; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(value = HttpStatus.FORBIDDEN, reason = "Requesting user not in audience for requested message") +public class UserNotInMessageAudienceException extends ForbiddenMessageException { + + private final Message messageNotForUser; + private final User userNotInMessageAudience; + + public UserNotInMessageAudienceException(Message message, User user) { + super("User not within audience of requested message"); + this.messageNotForUser = message; + this.userNotInMessageAudience = user; + } +} diff --git a/src/main/java/edu/wisc/my/messages/service/MessageIdPredicate.java b/src/main/java/edu/wisc/my/messages/service/MessageIdPredicate.java new file mode 100644 index 0000000..58f394b --- /dev/null +++ b/src/main/java/edu/wisc/my/messages/service/MessageIdPredicate.java @@ -0,0 +1,35 @@ +package edu.wisc.my.messages.service; + +import edu.wisc.my.messages.model.Message; +import java.util.Objects; +import java.util.function.Predicate; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Predicate that is true where the tested message has a (potentially null) ID matching that + * (potentially null) ID given at construction. + */ +public class MessageIdPredicate + implements Predicate { + + private final String idToMatch; + protected Logger logger = LoggerFactory.getLogger(getClass()); + + /** + * Instantiate a MessageIdPredicate that tests for a specific ID. + * + * @param idToMatch potentially null message identifier to look for + */ + public MessageIdPredicate(String idToMatch) { + this.idToMatch = idToMatch; + } + + @Override + public boolean test(Message message) { + boolean result = (null != message + && Objects.equals(this.idToMatch, message.getId())); + logger.trace("{} result testing for id {} in message {}", result, idToMatch, message); + return result; + } +} diff --git a/src/main/java/edu/wisc/my/messages/service/MessagesService.java b/src/main/java/edu/wisc/my/messages/service/MessagesService.java index cabd12b..7dc14c5 100644 --- a/src/main/java/edu/wisc/my/messages/service/MessagesService.java +++ b/src/main/java/edu/wisc/my/messages/service/MessagesService.java @@ -1,12 +1,17 @@ package edu.wisc.my.messages.service; import edu.wisc.my.messages.data.MessagesFromTextFile; +import edu.wisc.my.messages.exception.ExpiredMessageException; +import edu.wisc.my.messages.exception.PrematureMessageException; +import edu.wisc.my.messages.exception.UserNotInMessageAudienceException; import edu.wisc.my.messages.model.Message; import edu.wisc.my.messages.model.User; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; import java.util.function.Predicate; +import java.util.stream.Collectors; +import org.apache.commons.lang3.Validate; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; @@ -38,6 +43,8 @@ public List filteredMessages(User user) { validMessages.addAll(messageSource.allMessages()); validMessages.removeIf(retainMessage.negate()); // remove the messages we're not retaining + logger.trace("Found {} messages for user {}.", validMessages.size(), user); + return validMessages; } @@ -45,4 +52,86 @@ public List filteredMessages(User user) { public void setMessageSource(MessagesFromTextFile messageSource) { this.messageSource = messageSource; } + + /** + * Get the message with a given ID, or null if no such message + * + * @return Message matching ID, or null if none. + */ + public Message messageById(String idToMatch) { + logger.trace("messageById(\"{}\")", idToMatch); + Validate.notNull(idToMatch); + + Predicate messageMatchesRequestedId = new MessageIdPredicate(idToMatch); + + List allMessages = allMessages(); + + List matchingMessages = allMessages.stream().filter(messageMatchesRequestedId) + .collect(Collectors.toList()); + + if (matchingMessages.isEmpty()) { + logger.debug("Found no message for id [{}]", idToMatch); + return null; + } else if (matchingMessages.size() == 1) { + Message foundMessage = matchingMessages.get(0); + logger.trace("Found message [{}].", foundMessage); + return foundMessage; + } else { + logger.error("Multiple messages have id [{}]. Messages data corruption?", idToMatch); + throw new IllegalStateException("Multiple messages matched id [" + idToMatch + + "], which should have been a unique ID matching at most one message."); + } + } + + /** + * Get the message by id in the context of serving some user. + * + * @param id message ID + * @return the message with the given ID if that message should be given to the requesting user, + * null if not found + * @throws RuntimeException if id blank + * @throws NullPointerException if user null + * @throws ExpiredMessageException if requested message is expired + * @throws PrematureMessageException if requested message is not yet live + * @throws UserNotInMessageAudienceException if the message requires group membership and user + * lacks a sufficient group membership + */ + public Message messageByIdForUser(String id, User user) + throws PrematureMessageException, ExpiredMessageException, UserNotInMessageAudienceException { + Validate.notBlank(id); + Validate.notNull(user); + + Message messageWithRequestedId = messageById(id); + + if (null == messageWithRequestedId) { + logger.debug("No message with id [{}]"); + return null; + } + + LocalDateTime now = LocalDateTime.now(); + Predicate prematureMessagePredicate = + new GoneLiveMessagePredicate(now).negate(); + Predicate expiredMessagePredicate = + new ExpiredMessagePredicate(now); + Predicate userInAudienceMessagePredicate = + new AudienceFilterMessagePredicate(user); + + if (prematureMessagePredicate.test(messageWithRequestedId)) { + logger.debug("There is a message with id [{}] but it is not yet gone live. message: [{}]", + id, messageWithRequestedId); + throw new PrematureMessageException(messageWithRequestedId, now); + } else if (expiredMessagePredicate.test(messageWithRequestedId)) { + logger.debug("There is a message with id [{}] but it is expired. message: [{}]", + id, messageWithRequestedId); + throw new ExpiredMessageException(messageWithRequestedId, now); + } else if (!userInAudienceMessagePredicate.test(messageWithRequestedId)) { + logger.debug("There is a message with id [{}] but user [{}] is not in its audience.", + id, user); + throw new UserNotInMessageAudienceException(messageWithRequestedId, user); + } + + logger.trace("Found message with id [{}] as requested by [{}].", + id, user); + return messageWithRequestedId; + } } diff --git a/src/main/resources/messages.json b/src/main/resources/messages.json index 21e02ff..e9bd31c 100644 --- a/src/main/resources/messages.json +++ b/src/main/resources/messages.json @@ -304,6 +304,62 @@ "label": "More info" }, "confirmButton": null + }, + { + "id": "premature", + "title": "An announcement before its time.", + "titleShort": "Not yet gone live.", + "description": "This announcement is not live. Too soon..", + "descriptionShort": "Premature.", + "messageType": "announcement", + "featureImageUrl": null, + "priority": null, + "filter": { + "goLiveDate": "2999-01-01", + "expireDate": null + }, + "data": { + "dataUrl": null, + "dataObject": null, + "dataArrayFilter": null + }, + "actionButton": { + "label": "Call to action", + "url": "http://www.example.edu" + }, + "moreInfoButton": { + "url": "https://www.apereo.org/content/2018-open-apereo-montreal-quebec", + "label": "More info" + }, + "confirmButton": null + }, + { + "id": "expired", + "title": "An announcement after its time", + "titleShort": "Expired.", + "description": "This announcement is not live. Too late.", + "descriptionShort": "Expired.", + "messageType": "announcement", + "featureImageUrl": null, + "priority": null, + "filter": { + "goLiveDate": null, + "expireDate": "2000-01-01" + }, + "data": { + "dataUrl": null, + "dataObject": null, + "dataArrayFilter": null + }, + "actionButton": { + "label": "Call to action", + "url": "http://www.example.edu" + }, + "moreInfoButton": { + "url": "https://www.apereo.org/content/2018-open-apereo-montreal-quebec", + "label": "More info" + }, + "confirmButton": null } ] } diff --git a/src/test/java/edu/wisc/my/messages/controller/MessagesControllerTest.java b/src/test/java/edu/wisc/my/messages/controller/MessagesControllerTest.java index 7eb13a5..e377af8 100644 --- a/src/test/java/edu/wisc/my/messages/controller/MessagesControllerTest.java +++ b/src/test/java/edu/wisc/my/messages/controller/MessagesControllerTest.java @@ -41,4 +41,148 @@ public void siteIsUp() throws Exception { public void dataIsValid() { this.messageReader.allMessages(); } + + + /** + * Test the /admin/message/{id} path reading a message. + * + * @throws Exception as an unexpected test failure modality + */ + @Test + public void adminMessageById() throws Exception { + String expectedJson = "{\n" + + " \"id\": \"demo-high-priority-valid-group-no-date\",\n" + + " \"title\": \"Valid group. No date. High priority.\",\n" + + " \"titleShort\": \"Valid group. No date. High priority.\",\n" + + " \"titleUrl\": null,\n" + + " \"description\": \"Valid group. No date. High priority.\",\n" + + " \"descriptionShort\": \"Valid group. No date. High priority.\",\n" + + " \"messageType\": \"notification\",\n" + + " \"featureImageUrl\": null,\n" + + " \"priority\": \"high\",\n" + + " \"recurrence\": null,\n" + + " \"dismissible\": null,\n" + + " \"filter\": {\n" + + " \"goLiveDate\": null,\n" + + " \"expireDate\": null,\n" + + " \"groups\": [\n" + + " \"Portal Administrators\"\n" + + " ]\n" + + " },\n" + + " \"data\": {\n" + + " \"dataUrl\": null,\n" + + " \"dataObject\": null,\n" + + " \"dataArrayFilter\": null,\n" + + " \"dataMessageTitle\": null,\n" + + " \"dataMessageMoreInfoUrl\": null\n" + + " },\n" + + " \"actionButton\": {\n" + + " \"label\": \"Go\",\n" + + " \"url\": \"http://www.google.com\"\n" + + " },\n" + + " \"moreInfoButton\": null,\n" + + " \"confirmButton\": null\n" + + "}"; + + mvc.perform(MockMvcRequestBuilders.get("/admin/message/demo-high-priority-valid-group-no-date") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith("application/json")) + .andExpect(content().json(expectedJson)); + } + + /** + * Test that looking for a message by an ID that does not match yields a 404 NOT FOUND. + */ + @Test + public void adminNotFoundMessageYields404() throws Exception { + mvc.perform(MockMvcRequestBuilders.get("/admin/message/no-such-message") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()); + } + + /** + * Test the /admin/message/{id} path reading a message. + * + * @throws Exception as an unexpected test failure modality + */ + @Test + public void messageById() throws Exception { + String expectedJson = "{\n" + + " \"id\": \"has-no-audience-filter\",\n" + + " \"title\": \"An announcement lacking an audience filter.\",\n" + + " \"titleShort\": \"Not filtered by audience\",\n" + + " \"titleUrl\": null,\n" + + " \"description\": \"This announcement is not filtered by groups.\",\n" + + " \"descriptionShort\": \"Not filtered by groups.\",\n" + + " \"messageType\": \"announcement\",\n" + + " \"featureImageUrl\": null,\n" + + " \"priority\": null,\n" + + " \"recurrence\": null,\n" + + " \"dismissible\": null,\n" + + " \"filter\": null,\n" + + " \"data\": {\n" + + " \"dataUrl\": null,\n" + + " \"dataObject\": null,\n" + + " \"dataArrayFilter\": null,\n" + + " \"dataMessageTitle\": null,\n" + + " \"dataMessageMoreInfoUrl\": null\n" + + " },\n" + + " \"actionButton\": {\n" + + " \"label\": \"Add to home\",\n" + + " \"url\": \"addToHome/open-apereo\"\n" + + " },\n" + + " \"moreInfoButton\": {\n" + + " \"label\": \"More info\",\n" + + " \"url\": \"https://www.apereo.org/content/2018-open-apereo-montreal-quebec\"\n" + + " },\n" + + " \"confirmButton\": null\n" + + "}"; + + mvc.perform(MockMvcRequestBuilders.get("/message/has-no-audience-filter") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith("application/json")) + .andExpect(content().json(expectedJson)); + } + + /** + * Attempting to get a message you are not in the audience of yields 403 FORBIDDEN. + */ + @Test + public void notInAudienceMessageByIdYieldsError() throws Exception { + mvc.perform(MockMvcRequestBuilders.get("/message/1") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isForbidden()); + } + + /** + * Attempting to get an expired message yields 403 FORBIDDEN. + */ + @Test + public void expiredMessageByIdYieldsError() throws Exception { + mvc.perform(MockMvcRequestBuilders.get("/message/expired") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isForbidden()); + } + + /** + * Attempting to get premature message yields 403 FORBIDDEN. + */ + @Test + public void prematureMessageByIdYieldsError() throws Exception { + mvc.perform(MockMvcRequestBuilders.get("/message/premature") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isForbidden()); + } + + /** + * Test that looking for a message by an ID that does not match yields a 404 NOT FOUND. + */ + @Test + public void notFoundMessageYields404() throws Exception { + mvc.perform(MockMvcRequestBuilders.get("/admin/message/no-such-message") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()); + } } diff --git a/src/test/java/edu/wisc/my/messages/controller/MessagesControllerUnitTest.java b/src/test/java/edu/wisc/my/messages/controller/MessagesControllerUnitTest.java index 000b709..d632ac6 100644 --- a/src/test/java/edu/wisc/my/messages/controller/MessagesControllerUnitTest.java +++ b/src/test/java/edu/wisc/my/messages/controller/MessagesControllerUnitTest.java @@ -2,12 +2,15 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertSame; +import static org.mockito.Matchers.eq; import static org.mockito.Mockito.any; import static org.mockito.Mockito.anyString; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import edu.wisc.my.messages.exception.ForbiddenMessageException; +import edu.wisc.my.messages.exception.MessageNotFoundException; import edu.wisc.my.messages.model.Message; import edu.wisc.my.messages.model.User; import edu.wisc.my.messages.service.MessagesService; @@ -93,8 +96,6 @@ public void passesAllMessagesToView() { MessagesController controller = new MessagesController(); controller.setMessagesService(mockService); - HttpServletRequest mockRequest = mock(HttpServletRequest.class); - List messages = new ArrayList<>(); when(mockService.allMessages()).thenReturn(messages); @@ -104,4 +105,73 @@ public void passesAllMessagesToView() { assertSame(messages, result.get("messages")); } + @Test + public void passesSpecificMessageToView() throws MessageNotFoundException { + MessagesService mockService = mock(MessagesService.class); + + MessagesController controller = new MessagesController(); + controller.setMessagesService(mockService); + + Message matchingMessage = new Message(); + matchingMessage.setId("some-id"); + + when(mockService.messageById("some-id")).thenReturn(matchingMessage); + + Message result = controller.adminMessageById("some-id"); + + assertEquals(matchingMessage, result); + } + + @Test(expected = MessageNotFoundException.class) + public void throwsNotFoundExceptionWhenNoMessageByIdAdmin() + throws ForbiddenMessageException, MessageNotFoundException { + MessagesService mockService = mock(MessagesService.class); + + MessagesController controller = new MessagesController(); + controller.setMessagesService(mockService); + + when(mockService.messageByIdForUser(eq("some-id"), any())).thenReturn(null); + + Message resultMessage = controller.adminMessageById("some-id"); + } + + @Test + public void passesSpecificMessageToViewForUser() + throws ForbiddenMessageException, MessageNotFoundException { + MessagesService mockService = mock(MessagesService.class); + IsMemberOfHeaderParser mockParser = mock(IsMemberOfHeaderParser.class); + + MessagesController controller = new MessagesController(); + controller.setMessagesService(mockService); + controller.setIsMemberOfHeaderParser(mockParser); + + Message matchingMessage = new Message(); + matchingMessage.setId("some-id"); + + when(mockService.messageByIdForUser(eq("some-id"), any())).thenReturn(matchingMessage); + + HttpServletRequest mockRequest = mock(HttpServletRequest.class); + + Message resultMessage = controller.messageById("some-id", mockRequest); + + assertEquals(matchingMessage, resultMessage); + } + + @Test(expected = MessageNotFoundException.class) + public void throwsNotFoundExceptionWhenNoMessageById() + throws ForbiddenMessageException, MessageNotFoundException { + MessagesService mockService = mock(MessagesService.class); + IsMemberOfHeaderParser mockParser = mock(IsMemberOfHeaderParser.class); + + MessagesController controller = new MessagesController(); + controller.setMessagesService(mockService); + controller.setIsMemberOfHeaderParser(mockParser); + + when(mockService.messageByIdForUser(eq("some-id"), any())).thenReturn(null); + + HttpServletRequest mockRequest = mock(HttpServletRequest.class); + + Message resultMessage = controller.messageById("some-id", mockRequest); + } + } diff --git a/src/test/java/edu/wisc/my/messages/exception/ExpiredMessageExceptionTest.java b/src/test/java/edu/wisc/my/messages/exception/ExpiredMessageExceptionTest.java new file mode 100644 index 0000000..8202d92 --- /dev/null +++ b/src/test/java/edu/wisc/my/messages/exception/ExpiredMessageExceptionTest.java @@ -0,0 +1,12 @@ +package edu.wisc.my.messages.exception; + +import org.junit.Test; + +public class ExpiredMessageExceptionTest { + + @Test + public void copesWithNullConstructorArguments() { + new ExpiredMessageException(null, null); + } + +} diff --git a/src/test/java/edu/wisc/my/messages/exception/PrematureMessageExceptionTest.java b/src/test/java/edu/wisc/my/messages/exception/PrematureMessageExceptionTest.java new file mode 100644 index 0000000..bd6a91b --- /dev/null +++ b/src/test/java/edu/wisc/my/messages/exception/PrematureMessageExceptionTest.java @@ -0,0 +1,12 @@ +package edu.wisc.my.messages.exception; + +import org.junit.Test; + +public class PrematureMessageExceptionTest { + + @Test + public void copesWithNullConstructorArguments() { + new PrematureMessageException(null, null); + } + +} diff --git a/src/test/java/edu/wisc/my/messages/service/MessageIdPredicateTest.java b/src/test/java/edu/wisc/my/messages/service/MessageIdPredicateTest.java new file mode 100644 index 0000000..586a6c4 --- /dev/null +++ b/src/test/java/edu/wisc/my/messages/service/MessageIdPredicateTest.java @@ -0,0 +1,69 @@ +package edu.wisc.my.messages.service; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import edu.wisc.my.messages.model.Message; +import org.junit.Test; + +public class MessageIdPredicateTest { + + @Test + public void nullMessageTestsFalse() { + MessageIdPredicate predicate = new MessageIdPredicate("does-not-matter"); + assertFalse(predicate.test(null)); + } + + @Test + public void mismatchIdTestsFalse() { + MessageIdPredicate predicate = new MessageIdPredicate("a-particular-id"); + Message message = new Message(); + message.setId("not-that-particular-id"); + + assertFalse(predicate.test(message)); + } + + @Test + public void matchingIdTestsTrue() { + MessageIdPredicate predicate = new MessageIdPredicate("a-particular-id"); + Message message = new Message(); + message.setId("a-particular-id"); + + assertTrue(predicate.test(message)); + } + + @Test + public void matchingNullIdTestsTrue() { + MessageIdPredicate predicate = new MessageIdPredicate(null); + Message messageWithNullId = new Message(); + + assertTrue(predicate.test(messageWithNullId)); + } + + + /** + * Test that testing a message with a non-null id for a null message ID evaluates correctly false + * and does not NullPointer out. + */ + @Test + public void notMatchingPredicateLookingForNullTestsFalse() { + MessageIdPredicate messageHasNullIdPredicate = new MessageIdPredicate(null); + Message messageWithNonNullId = new Message(); + messageWithNonNullId.setId("not-null-id"); + + assertFalse(messageHasNullIdPredicate.test(messageWithNonNullId)); + } + + /** + * Test that testing a message with a null id for a non-null message ID evaluates correctly false + * and does not NullPointer out. + */ + @Test + public void nullMessageIdNotMatchingPredicateLookingForNonNullTestsFalse() { + MessageIdPredicate someMessageIdPredicate = new MessageIdPredicate("some-id"); + Message messageWithNullId = new Message(); + + assertFalse(someMessageIdPredicate.test(messageWithNullId)); + } + +} diff --git a/src/test/java/edu/wisc/my/messages/service/MessagesServiceTest.java b/src/test/java/edu/wisc/my/messages/service/MessagesServiceTest.java index 7c63ea4..2125cf6 100644 --- a/src/test/java/edu/wisc/my/messages/service/MessagesServiceTest.java +++ b/src/test/java/edu/wisc/my/messages/service/MessagesServiceTest.java @@ -2,12 +2,17 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import static org.mockito.Matchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import edu.wisc.my.messages.data.MessagesFromTextFile; +import edu.wisc.my.messages.exception.ExpiredMessageException; +import edu.wisc.my.messages.exception.ForbiddenMessageException; +import edu.wisc.my.messages.exception.PrematureMessageException; +import edu.wisc.my.messages.exception.UserNotInMessageAudienceException; import edu.wisc.my.messages.model.Message; import edu.wisc.my.messages.model.MessageFilter; import edu.wisc.my.messages.model.User; @@ -165,4 +170,203 @@ public void includesUnExpiredMessages() { assertEquals(2, result.size()); } + /** + * Test that returns the message matching a given ID. + */ + @Test + public void returnsMessageMatchingId() { + MessagesService service = new MessagesService(); + + Message firstMessage = new Message(); + firstMessage.setId("uniqueMessageId-1"); + + Message secondMessage = new Message(); + secondMessage.setId("anotherMessageId-2"); + + List messagesFromRepository = new ArrayList<>(); + messagesFromRepository.add(firstMessage); + messagesFromRepository.add(secondMessage); + + MessagesFromTextFile messageSource = mock(MessagesFromTextFile.class); + when(messageSource.allMessages()).thenReturn(messagesFromRepository); + + service.setMessageSource(messageSource); + + Message result = service.messageById("uniqueMessageId-1"); + + assertEquals(firstMessage, result); + } + + @Test + public void returnsNullWhenNoMatchingId() { + MessagesService service = new MessagesService(); + + Message firstMessage = new Message(); + firstMessage.setId("uniqueMessageId-1"); + + Message secondMessage = new Message(); + secondMessage.setId("anotherMessageId-2"); + + List messagesFromRepository = new ArrayList<>(); + messagesFromRepository.add(firstMessage); + messagesFromRepository.add(secondMessage); + + MessagesFromTextFile messageSource = mock(MessagesFromTextFile.class); + when(messageSource.allMessages()).thenReturn(messagesFromRepository); + + service.setMessageSource(messageSource); + + Message result = service.messageById("no-message-with-this-id"); + + assertNull(result); + } + + @Test(expected = IllegalStateException.class) + public void throwsIllegalStateExceptionWhenMultipleMessagesMatchId() { + MessagesService service = new MessagesService(); + + Message firstMessage = new Message(); + firstMessage.setId("not-so-unique-id"); + + Message secondMessage = new Message(); + secondMessage.setId("not-so-unique-id"); + + List messagesFromRepository = new ArrayList<>(); + messagesFromRepository.add(firstMessage); + messagesFromRepository.add(secondMessage); + + MessagesFromTextFile messageSource = mock(MessagesFromTextFile.class); + when(messageSource.allMessages()).thenReturn(messagesFromRepository); + + service.setMessageSource(messageSource); + + Message result = service.messageById("not-so-unique-id"); + } + + @Test(expected = NullPointerException.class) + public void requestingMessageWithNullIdThrowsNPE() { + MessagesService service = new MessagesService(); + service.messageById(null); + } + + + @Test + public void requestAsUserMessageWithUnknownIdReturnsNull() + throws ForbiddenMessageException { + MessagesService service = new MessagesService(); + + List messagesFromRepository = new ArrayList<>(); + + MessagesFromTextFile messageSource = mock(MessagesFromTextFile.class); + when(messageSource.allMessages()).thenReturn(messagesFromRepository); + + service.setMessageSource(messageSource); + + assertNull(service.messageByIdForUser("id-does-not-match-any-message", new User())); + } + + /** + * Test the happy path, that a user successfully reads a current message for which the user is an + * audience member. + */ + @Test + public void userInAudienceSucccessfullyReadsCurrentMessage() + throws ForbiddenMessageException { + MessagesService service = new MessagesService(); + + MessageFilter yesFilter = mock(MessageFilter.class); + when(yesFilter.test(any())).thenReturn(true); + Message matchingMessage = new Message(); + matchingMessage.setFilter(yesFilter); + matchingMessage.setId("yes-in-audience-of-this-message"); + + List unfilteredMessages = new ArrayList<>(); + unfilteredMessages.add(matchingMessage); + + MessagesFromTextFile messageSource = mock(MessagesFromTextFile.class); + when(messageSource.allMessages()).thenReturn(unfilteredMessages); + + service.setMessageSource(messageSource); + + User user = new User(); + + Message result = service.messageByIdForUser("yes-in-audience-of-this-message", user); + + assertEquals(matchingMessage, result); + } + + @Test(expected = ExpiredMessageException.class) + public void userInAudienceCannotReadExpiredMessage() + throws ForbiddenMessageException { + MessagesService service = new MessagesService(); + + MessageFilter yesFilter = mock(MessageFilter.class); + when(yesFilter.test(any())).thenReturn(true); + when(yesFilter.getExpireDate()).thenReturn("2001-01-01"); + Message matchingMessage = new Message(); + matchingMessage.setFilter(yesFilter); + matchingMessage.setId("in-audience-of-this-expired-message"); + + List unfilteredMessages = new ArrayList<>(); + unfilteredMessages.add(matchingMessage); + + MessagesFromTextFile messageSource = mock(MessagesFromTextFile.class); + when(messageSource.allMessages()).thenReturn(unfilteredMessages); + + service.setMessageSource(messageSource); + + User user = new User(); + + Message result = service.messageByIdForUser("in-audience-of-this-expired-message", user); + } + + @Test(expected = PrematureMessageException.class) + public void userInAudienceCannotReadPrematureMessage() + throws ForbiddenMessageException { + MessagesService service = new MessagesService(); + + MessageFilter yesFilter = mock(MessageFilter.class); + when(yesFilter.test(any())).thenReturn(true); + when(yesFilter.getGoLiveDate()).thenReturn("2999-01-01"); + Message matchingMessage = new Message(); + matchingMessage.setFilter(yesFilter); + matchingMessage.setId("yes-in-audience-of-this-message"); + + List unfilteredMessages = new ArrayList<>(); + unfilteredMessages.add(matchingMessage); + + MessagesFromTextFile messageSource = mock(MessagesFromTextFile.class); + when(messageSource.allMessages()).thenReturn(unfilteredMessages); + + service.setMessageSource(messageSource); + + User user = new User(); + + Message result = service.messageByIdForUser("yes-in-audience-of-this-message", user); + } + + @Test(expected = UserNotInMessageAudienceException.class) + public void userNotInAudienceCannotReadMessage() + throws ForbiddenMessageException { + MessagesService service = new MessagesService(); + + MessageFilter noFilter = mock(MessageFilter.class); + when(noFilter.test(any())).thenReturn(false); + Message matchingMessage = new Message(); + matchingMessage.setFilter(noFilter); + matchingMessage.setId("not-in-audience-of-this-message"); + + List unfilteredMessages = new ArrayList<>(); + unfilteredMessages.add(matchingMessage); + + MessagesFromTextFile messageSource = mock(MessagesFromTextFile.class); + when(messageSource.allMessages()).thenReturn(unfilteredMessages); + + service.setMessageSource(messageSource); + + User user = new User(); + + Message result = service.messageByIdForUser("not-in-audience-of-this-message", user); + } + }