diff --git a/README.md b/README.md index a605a6943..e96122532 100644 --- a/README.md +++ b/README.md @@ -301,6 +301,7 @@ The special E2E client settings should be defined in `env.yml`: | TRIP_INSTRUCTION_IMMEDIATE_RADIUS | integer | Optional | 2 | The radius in meters under which an immediate instruction is given. | | TRIP_INSTRUCTION_UPCOMING_RADIUS | integer | Optional | 10 | The radius in meters under which an upcoming instruction is given. | | TWILIO_ACCOUNT_SID | string | Optional | your-account-sid | Twilio settings available at: https://twilio.com/user/account | +| TRUSTED_COMPANION_CONFIRMATION_PAGE_URL | string | Optional | https://otp-server.example.com/trusted/confirmation | URL to the trusted companion confirmation page. This page should support handling an error URL parameter. | | TWILIO_AUTH_TOKEN | string | Optional | your-auth-token | Twilio settings available at: https://twilio.com/user/account | | US_RIDE_GWINNETT_BUS_OPERATOR_NOTIFIER_API_URL | string | Optional | http://host.example.com | US RideGwinnett bus notifier API. | | US_RIDE_GWINNETT_BUS_OPERATOR_NOTIFIER_API_KEY | string | Optional | your-api-key | API key for the US RideGwinnett bus notifier API. | diff --git a/configurations/default/env.yml.tmp b/configurations/default/env.yml.tmp index 93730bf49..6665b1522 100644 --- a/configurations/default/env.yml.tmp +++ b/configurations/default/env.yml.tmp @@ -109,3 +109,4 @@ MAXIMUM_MONITORED_TRIP_ITINERARY_CHECKS: 3 # The location for an OTP plan query request. PLAN_QUERY_RESOURCE_URI: https://plan.resource.com +TRUSTED_COMPANION_CONFIRMATION_PAGE_URL: https://otp-server.example.com/trusted/confirmation \ No newline at end of file diff --git a/src/main/java/org/opentripplanner/middleware/OtpMiddlewareMain.java b/src/main/java/org/opentripplanner/middleware/OtpMiddlewareMain.java index 85c263b4d..72c0811f3 100644 --- a/src/main/java/org/opentripplanner/middleware/OtpMiddlewareMain.java +++ b/src/main/java/org/opentripplanner/middleware/OtpMiddlewareMain.java @@ -41,6 +41,7 @@ import static org.opentripplanner.middleware.bugsnag.BugsnagWebhook.processWebHookDelivery; import static org.opentripplanner.middleware.controllers.api.ApiUserController.API_USER_PATH; import static org.opentripplanner.middleware.controllers.api.ApiUserController.AUTHENTICATE_PATH; +import static org.opentripplanner.middleware.tripmonitor.TrustedCompanion.ACCEPT_DEPENDENT_PATH; import static org.opentripplanner.middleware.utils.JsonUtils.logMessageAndHalt; /** @@ -172,7 +173,11 @@ private static void initializeHttpEndpoints() throws IOException, InterruptedExc // Security checks for admin and /secure/ endpoints. Excluding /authenticate so that API users can obtain a // bearer token to authenticate against all other /secure/ endpoints. spark.before(API_PREFIX + "/secure/*", ((request, response) -> { - if (!request.requestMethod().equals("OPTIONS") && !request.pathInfo().endsWith(API_USER_PATH + AUTHENTICATE_PATH)) { + if ( + !request.requestMethod().equals("OPTIONS") && + !request.pathInfo().endsWith(API_USER_PATH + AUTHENTICATE_PATH) && + !request.pathInfo().endsWith(ACCEPT_DEPENDENT_PATH) + ) { Auth0Connection.checkUser(request); } })); diff --git a/src/main/java/org/opentripplanner/middleware/controllers/api/OtpUserController.java b/src/main/java/org/opentripplanner/middleware/controllers/api/OtpUserController.java index b91df942f..096518d9e 100644 --- a/src/main/java/org/opentripplanner/middleware/controllers/api/OtpUserController.java +++ b/src/main/java/org/opentripplanner/middleware/controllers/api/OtpUserController.java @@ -1,17 +1,18 @@ package org.opentripplanner.middleware.controllers.api; -import io.github.manusant.ss.ApiEndpoint; import com.twilio.rest.verify.v2.service.Verification; import com.twilio.rest.verify.v2.service.VerificationCheck; +import io.github.manusant.ss.ApiEndpoint; import org.apache.commons.lang3.StringUtils; import org.eclipse.jetty.http.HttpStatus; import org.opentripplanner.middleware.auth.Auth0Connection; import org.opentripplanner.middleware.auth.RequestingUser; -import org.opentripplanner.middleware.models.MobilityProfile; import org.opentripplanner.middleware.models.OtpUser; import org.opentripplanner.middleware.persistence.Persistence; +import org.opentripplanner.middleware.tripmonitor.TrustedCompanion; import org.opentripplanner.middleware.utils.JsonUtils; import org.opentripplanner.middleware.utils.NotificationUtils; +import org.opentripplanner.middleware.utils.SwaggerUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import spark.Request; @@ -23,6 +24,10 @@ import java.util.regex.Pattern; import static io.github.manusant.ss.descriptor.MethodDescriptor.path; +import static org.opentripplanner.middleware.tripmonitor.TrustedCompanion.ACCEPT_KEY; +import static org.opentripplanner.middleware.tripmonitor.TrustedCompanion.USER_LOCALE; +import static org.opentripplanner.middleware.tripmonitor.TrustedCompanion.ensureRelatedUserIntegrity; +import static org.opentripplanner.middleware.tripmonitor.TrustedCompanion.manageAcceptDependentEmail; import static org.opentripplanner.middleware.utils.JsonUtils.logMessageAndHalt; /** @@ -33,11 +38,18 @@ public class OtpUserController extends AbstractUserController { private static final Logger LOG = LoggerFactory.getLogger(OtpUserController.class); private static final String CODE_PARAM = "code"; + private static final String PHONE_PARAM = "phoneNumber"; + private static final String VERIFY_PATH = "verify_sms"; + public static final String OTP_USER_PATH = "secure/user"; + private static final String VERIFY_ROUTE_TEMPLATE = "/:%s/%s/:%s"; - /** Regex to check E.164 phone number format per https://www.twilio.com/docs/glossary/what-e164 */ + + /** + * Regex to check E.164 phone number format per https://www.twilio.com/docs/glossary/what-e164 + */ private static final Pattern PHONE_E164_PATTERN = Pattern.compile("^\\+[1-9]\\d{1,14}$"); public OtpUserController(String apiPrefix) { @@ -53,18 +65,25 @@ OtpUser preCreateHook(OtpUser user, Request req) { Auth0Connection.ensureApiUserHasApiKey(req); user.applicationId = requestingUser.apiUser.id; } - if (Objects.nonNull(user.mobilityProfile)) { - user.mobilityProfile.updateMobilityMode(); - } + preliminaryTasks(user); return super.preCreateHook(user, req); } @Override OtpUser preUpdateHook(OtpUser user, OtpUser preExistingUser, Request req) { + preliminaryTasks(user); + ensureRelatedUserIntegrity(user, preExistingUser); + return super.preUpdateHook(user, preExistingUser, req); + } + + /** + * Tasks to be carried out before creating or updating a user. + */ + private void preliminaryTasks(OtpUser user) { if (Objects.nonNull(user.mobilityProfile)) { user.mobilityProfile.updateMobilityMode(); } - return super.preUpdateHook(user, preExistingUser, req); + manageAcceptDependentEmail(user); } @Override @@ -73,6 +92,14 @@ protected void buildEndpoint(ApiEndpoint baseEndpoint) { // Add the api key route BEFORE the regular CRUD methods ApiEndpoint modifiedEndpoint = baseEndpoint + .get(path("/acceptdependent") + .withDescription("Accept a dependent request.") + .withResponses(SwaggerUtils.createStandardResponses(OtpUser.class)) + .withPathParam().withName(ACCEPT_KEY).withRequired(true).withDescription("The accept dependent unique key.").and() + .withPathParam().withName(USER_LOCALE).withRequired(true).withDescription("The accepting user's locale.").and() + .withResponseType(OtpUser.class), + TrustedCompanion::acceptDependent + ) .get(path(ROOT_ROUTE + String.format(VERIFY_ROUTE_TEMPLATE, ID_PARAM, VERIFY_PATH, PHONE_PARAM)) .withDescription("Request an SMS verification to be sent to an OtpUser's phone number.") .withPathParam().withName(ID_PARAM).withRequired(true).withDescription("The id of the OtpUser.").and() @@ -183,4 +210,4 @@ public static boolean isPhoneNumberValidE164(String phoneNumber) { Matcher m = PHONE_E164_PATTERN.matcher(phoneNumber); return m.matches(); } -} +} \ No newline at end of file diff --git a/src/main/java/org/opentripplanner/middleware/i18n/Message.java b/src/main/java/org/opentripplanner/middleware/i18n/Message.java index d3946e31f..c5c3271fa 100644 --- a/src/main/java/org/opentripplanner/middleware/i18n/Message.java +++ b/src/main/java/org/opentripplanner/middleware/i18n/Message.java @@ -13,6 +13,12 @@ * Message.properties. */ public enum Message { + ACCEPT_DEPENDENT_EMAIL_FOOTER, + ACCEPT_DEPENDENT_EMAIL_GREETING, + ACCEPT_DEPENDENT_EMAIL_LINK_TEXT, + ACCEPT_DEPENDENT_EMAIL_SUBJECT, + ACCEPT_DEPENDENT_EMAIL_MANAGE, + ACCEPT_DEPENDENT_ERROR, LABEL_AND_CONTENT, SMS_STOP_NOTIFICATIONS, TRIP_EMAIL_SUBJECT, diff --git a/src/main/java/org/opentripplanner/middleware/models/MonitoredTrip.java b/src/main/java/org/opentripplanner/middleware/models/MonitoredTrip.java index 6d0e25ef9..dec7cb2e4 100644 --- a/src/main/java/org/opentripplanner/middleware/models/MonitoredTrip.java +++ b/src/main/java/org/opentripplanner/middleware/models/MonitoredTrip.java @@ -25,6 +25,7 @@ import java.time.LocalDate; import java.time.LocalTime; import java.time.ZonedDateTime; +import java.util.ArrayList; import java.util.List; import java.util.function.Function; @@ -173,6 +174,10 @@ public class MonitoredTrip extends Model { */ public boolean notifyAtLeadingInterval = true; + public RelatedUser primary; + public RelatedUser companion; + public List observers = new ArrayList<>(); + /** * The number of attempts made to obtain a trip's itinerary from OTP which matches this trip. */ diff --git a/src/main/java/org/opentripplanner/middleware/models/OtpUser.java b/src/main/java/org/opentripplanner/middleware/models/OtpUser.java index 495d35954..79de4104c 100644 --- a/src/main/java/org/opentripplanner/middleware/models/OtpUser.java +++ b/src/main/java/org/opentripplanner/middleware/models/OtpUser.java @@ -9,10 +9,17 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.*; + +import java.util.ArrayList; +import java.util.Date; +import java.util.EnumSet; +import java.util.List; +import java.util.Objects; import java.util.stream.Collectors; import java.util.stream.Stream; +import static org.opentripplanner.middleware.tripmonitor.TrustedCompanion.removeDependent; + /** * This represents a user of an OpenTripPlanner instance (typically of the standard OTP UI/otp-react-redux). * otp-middleware stores these users and associated information (e.g., home/work locations and other favorites). Users @@ -43,7 +50,7 @@ public enum Notification { * Notification preferences for this user * (EMAIL and/or SMS and/or PUSH). */ - public EnumSet notificationChannel = EnumSet.noneOf(OtpUser.Notification.class); + public EnumSet notificationChannel = EnumSet.noneOf(OtpUser.Notification.class); /** * Verified phone number for SMS notifications, in +15551234 format (E.164 format, includes country code, no spaces). @@ -80,6 +87,15 @@ public enum Notification { /** If this user was created by an {@link ApiUser}, this parameter will match the {@link ApiUser}'s id */ public String applicationId; + /** Companions and observers of this user. */ + public List relatedUsers = new ArrayList<>(); + + /** Users that are dependent on this user. */ + public List dependents = new ArrayList<>(); + + /** This user's name */ + public String name; + @Override public boolean delete() { return delete(true); @@ -113,6 +129,23 @@ public boolean delete(boolean deleteAuth0User) { } } + // If a related user, invalidate relationship with all dependents. + for (String userId : dependents) { + OtpUser dependent = Persistence.otpUsers.getById(userId); + if (dependent != null) { + for (RelatedUser relatedUser : dependent.relatedUsers) { + if (relatedUser.email.equals(this.email)) { + relatedUser.status = RelatedUser.RelatedUserStatus.INVALID; + } + } + Persistence.otpUsers.replace(dependent.id, dependent); + } + } + + // If a dependent, remove relationship with all related users. + for (RelatedUser relatedUser : relatedUsers) { + removeDependent(this, relatedUser); + } return Persistence.otpUsers.removeById(this.id); } diff --git a/src/main/java/org/opentripplanner/middleware/models/RelatedUser.java b/src/main/java/org/opentripplanner/middleware/models/RelatedUser.java new file mode 100644 index 000000000..920e94eda --- /dev/null +++ b/src/main/java/org/opentripplanner/middleware/models/RelatedUser.java @@ -0,0 +1,29 @@ +package org.opentripplanner.middleware.models; + +/** A related user is a companion or observer requested by a dependent. */ +public class RelatedUser { + public enum RelatedUserStatus { + PENDING, CONFIRMED, INVALID + } + + public String email; + public RelatedUserStatus status = RelatedUserStatus.PENDING; + public String acceptKey; + public String nickname; + + public RelatedUser() { + // Required for JSON deserialization. + } + + public RelatedUser(String email, RelatedUserStatus status, String nickname) { + this.email = email; + this.status = status; + this.nickname = nickname; + } + + public RelatedUser(String email, RelatedUserStatus status, String nickname, String acceptKey) { + this (email, status, nickname); + this.acceptKey = acceptKey; + } +} + diff --git a/src/main/java/org/opentripplanner/middleware/tripmonitor/TrustedCompanion.java b/src/main/java/org/opentripplanner/middleware/tripmonitor/TrustedCompanion.java new file mode 100644 index 000000000..aeb23d611 --- /dev/null +++ b/src/main/java/org/opentripplanner/middleware/tripmonitor/TrustedCompanion.java @@ -0,0 +1,238 @@ +package org.opentripplanner.middleware.tripmonitor; + +import com.mongodb.client.model.Filters; +import org.apache.logging.log4j.util.Strings; +import org.opentripplanner.middleware.OtpMiddlewareMain; +import org.opentripplanner.middleware.i18n.Message; +import org.opentripplanner.middleware.models.OtpUser; +import org.opentripplanner.middleware.models.RelatedUser; +import org.opentripplanner.middleware.persistence.Persistence; +import org.opentripplanner.middleware.utils.ConfigUtils; +import org.opentripplanner.middleware.utils.HttpUtils; +import org.opentripplanner.middleware.utils.I18nUtils; +import org.opentripplanner.middleware.utils.NotificationUtils; +import spark.Request; +import spark.Response; + +import java.net.URLEncoder; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.stream.Collectors; + +import static com.mongodb.client.model.Filters.eq; +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.opentripplanner.middleware.tripmonitor.jobs.CheckMonitoredTrip.SETTINGS_PATH; +import static org.opentripplanner.middleware.utils.I18nUtils.getLocaleFromString; +import static org.opentripplanner.middleware.utils.I18nUtils.label; + +public class TrustedCompanion { + + private TrustedCompanion() { + throw new IllegalStateException("Utility class"); + } + + private static final String AWS_API_SERVER = ConfigUtils.getConfigPropertyAsText("AWS_API_SERVER"); + private static final String AWS_API_STAGE = ConfigUtils.getConfigPropertyAsText("AWS_API_STAGE"); + private static final String OTP_UI_URL = ConfigUtils.getConfigPropertyAsText("OTP_UI_URL"); + private static final String TRUSTED_COMPANION_CONFIRMATION_PAGE_URL = + ConfigUtils.getConfigPropertyAsText("TRUSTED_COMPANION_CONFIRMATION_PAGE_URL"); + public static final String ACCEPT_KEY = "acceptKey"; + public static final String USER_LOCALE = "userLocale"; + public static final String EMAIL_FIELD_NAME = "email"; + + /** Note: This path is excluded from security checks, see {@link OtpMiddlewareMain#initializeHttpEndpoints()}. */ + public static final String ACCEPT_DEPENDENT_PATH = "api/secure/user/acceptdependent"; + + /** + * Accept a request from another user to be their dependent. This will include both companions and observers. If + * successful redirect the user to the confirmation page, else redirect to the error page with related error. + */ + public static OtpUser acceptDependent(Request request, Response response) { + Locale locale = getUserLocaleFromRequest(request); + try { + String acceptKey = getAcceptKeyFromRequest(request); + OtpUser dependentUser = getUserFromAcceptKey(acceptKey); + OtpUser relatedUser = getRelatedUserFromEmail(dependentUser, acceptKey); + + if (relatedUser != null) { + Optional relatedUserToUpdate = dependentUser.relatedUsers + .stream() + .filter(related -> related.email.equals(relatedUser.email)) + .findFirst(); + relatedUserToUpdate.ifPresent(value -> value.status = RelatedUser.RelatedUserStatus.CONFIRMED); + + // Maintain a list of dependents. + relatedUser.dependents.add(dependentUser.id); + Persistence.otpUsers.replace(relatedUser.id, relatedUser); + // Update list of related users. + Persistence.otpUsers.replace(dependentUser.id, dependentUser); + + // Redirect to confirmation page and provide dependent user information. + response.redirect(TRUSTED_COMPANION_CONFIRMATION_PAGE_URL); + return dependentUser; + } + } catch (IllegalArgumentException e) { + response.redirect(String.format( + "%s?%s", + TRUSTED_COMPANION_CONFIRMATION_PAGE_URL, + URLEncoder.encode(String.format("error=%s", Message.ACCEPT_DEPENDENT_ERROR.get(locale)), UTF_8) + )); + } + return null; + } + + /** + * Using the accept key, find the matching related user's email and from that return the related user. + */ + private static OtpUser getRelatedUserFromEmail(OtpUser dependentUser, String acceptKey) { + if (dependentUser == null || acceptKey == null) { + return null; + } + Optional relatedUser = dependentUser.relatedUsers + .stream() + .filter(user -> user.acceptKey.equalsIgnoreCase(acceptKey)) + .findFirst(); + return relatedUser.map(user -> Persistence.otpUsers.getOneFiltered(eq(EMAIL_FIELD_NAME, user.email))).orElse(null); + } + + /** + * Extract the accept key from the request parameters. + */ + private static String getAcceptKeyFromRequest(Request request) throws IllegalArgumentException { + // Note: optional is true so a missing accept key will be handled here. + String acceptKey = HttpUtils.getQueryParamFromRequest(request, ACCEPT_KEY, true); + if (Strings.isBlank(acceptKey)) { + throw new IllegalArgumentException("Accept key not provided."); + } + return acceptKey; + } + + /** + * Extract the user's language tag from the request and return the {@link Locale} from it. + */ + private static Locale getUserLocaleFromRequest(Request request) throws IllegalArgumentException { + // Note: optional is true so a missing locale will be handled here. + String languageTag = HttpUtils.getQueryParamFromRequest(request, USER_LOCALE, true); + return getLocaleFromString(languageTag); + } + + /** + * Retrieve the dependent user matching the accept key. + */ + private static OtpUser getUserFromAcceptKey(String acceptKey) throws IllegalArgumentException { + if (Strings.isBlank(acceptKey)) { + return null; + } + OtpUser user = getUserForAcceptKey(acceptKey); + if (user == null) { + throw new IllegalArgumentException("OTP user unknown."); + } + return user; + } + + public static void manageAcceptDependentEmail(OtpUser dependentUser) { + manageAcceptDependentEmail(dependentUser, false); + } + + /** + * When creating or updating an OTP user, extract a list of newly defined dependents and send an 'accept dependent' + * email to each. Then update which dependents have been sent an email so subsequent updates do not trigger + * additional emails. + */ + public static void manageAcceptDependentEmail(OtpUser dependentUser, boolean isTest) { + if (dependentUser.relatedUsers.isEmpty()) { + // No related users defined by dependent. + return; + } + + dependentUser.relatedUsers + .stream() + .filter(relatedUser -> relatedUser.acceptKey == null) + .forEach(relatedUser -> { + String acceptKey = UUID.randomUUID().toString(); + OtpUser userToReceiveEmail = Persistence.otpUsers.getOneFiltered(eq(EMAIL_FIELD_NAME, relatedUser.email)); + if (userToReceiveEmail != null && (isTest || sendAcceptDependentEmail(dependentUser, userToReceiveEmail, acceptKey))) { + relatedUser.acceptKey = acceptKey; + } + }); + + // Preserve email sent status (by storing the accept key). + Persistence.otpUsers.replace(dependentUser.id, dependentUser); + } + + /** + * Send 'accept dependent' email. + */ + private static boolean sendAcceptDependentEmail(OtpUser dependentUser, OtpUser relatedUser, String acceptKey) { + Locale locale = I18nUtils.getOtpUserLocale(relatedUser); + + String acceptDependentLinkLabel = Message.ACCEPT_DEPENDENT_EMAIL_LINK_TEXT.get(locale); + String acceptDependentUrl = getAcceptDependentUrl(acceptKey, locale); + String addressee = (Strings.isBlank(dependentUser.name)) ? dependentUser.email : dependentUser.name; + + // A HashMap is needed instead of a Map for template data to be serialized to the template renderer. + Map templateData = new HashMap<>( + Map.of( + "acceptDependentLinkAnchorLabel", acceptDependentLinkLabel, + "acceptDependentLinkLabelAndUrl", label(acceptDependentLinkLabel, acceptDependentUrl, locale), + "acceptDependentUrl", acceptDependentUrl, + "emailFooter", Message.ACCEPT_DEPENDENT_EMAIL_FOOTER.get(locale), + "emailGreeting", String.format(Message.ACCEPT_DEPENDENT_EMAIL_GREETING.get(locale), addressee), + "manageLinkUrl", String.format("%s%s", OTP_UI_URL, SETTINGS_PATH), + "manageLinkText", Message.ACCEPT_DEPENDENT_EMAIL_MANAGE.get(locale) + ) + ); + + return NotificationUtils.sendEmail( + relatedUser, + Message.ACCEPT_DEPENDENT_EMAIL_SUBJECT.get(locale), + "AcceptDependentText.ftl", + "AcceptDependentHtml.ftl", + templateData + ); + } + + private static String getAcceptDependentUrl(String acceptKey, Locale locale) { + return String.format("%s/%s%s", AWS_API_SERVER, AWS_API_STAGE, getAcceptDependentEndPoint(acceptKey, locale)); + } + + public static String getAcceptDependentEndPoint(String acceptKey, Locale locale) { + return String.format("/%s?%s=%s&%s=%s", ACCEPT_DEPENDENT_PATH, ACCEPT_KEY, acceptKey, USER_LOCALE, locale.toLanguageTag()); + } + + /** + * @return the {@link OtpUser} found with a {@link RelatedUser#acceptKey} in {@link OtpUser#relatedUsers} that + * matches the provided acceptKey. + */ + private static OtpUser getUserForAcceptKey(String acceptKey) { + return Persistence.otpUsers.getOneFiltered(Filters.elemMatch("relatedUsers", Filters.eq(ACCEPT_KEY, acceptKey))); + } + + /** + * If a dependent removes a related user, remove the dependent from the related user. + */ + public static void ensureRelatedUserIntegrity(OtpUser updatedUser, OtpUser preExistingUser) { + List difference = preExistingUser.relatedUsers + .stream() + .filter(relatedUser -> !updatedUser.relatedUsers.contains(relatedUser)) + .collect(Collectors.toList()); + for (RelatedUser relatedUser : difference) { + removeDependent(updatedUser, relatedUser); + } + } + + /** + * Remove the dependent reference from the related user. + */ + public static void removeDependent(OtpUser dependent, RelatedUser relatedUser) { + OtpUser user = Persistence.otpUsers.getOneFiltered(eq(EMAIL_FIELD_NAME, relatedUser.email)); + if (user != null) { + user.dependents.remove(dependent.id); + Persistence.otpUsers.replace(user.id, user); + } + } +} diff --git a/src/main/java/org/opentripplanner/middleware/tripmonitor/jobs/CheckMonitoredTrip.java b/src/main/java/org/opentripplanner/middleware/tripmonitor/jobs/CheckMonitoredTrip.java index 081bbbc66..0ca1d2095 100644 --- a/src/main/java/org/opentripplanner/middleware/tripmonitor/jobs/CheckMonitoredTrip.java +++ b/src/main/java/org/opentripplanner/middleware/tripmonitor/jobs/CheckMonitoredTrip.java @@ -57,11 +57,11 @@ public class CheckMonitoredTrip implements Runnable { public static final int MAXIMUM_MONITORED_TRIP_ITINERARY_CHECKS = ConfigUtils.getConfigPropertyAsInt("MAXIMUM_MONITORED_TRIP_ITINERARY_CHECKS", 3); - private final String ACCOUNT_PATH = "/#/account"; + public static final String ACCOUNT_PATH = "/#/account"; private final String TRIPS_PATH = ACCOUNT_PATH + "/trips"; - private final String SETTINGS_PATH = ACCOUNT_PATH + "/settings"; + public static final String SETTINGS_PATH = ACCOUNT_PATH + "/settings"; public final MonitoredTrip trip; diff --git a/src/main/java/org/opentripplanner/middleware/utils/I18nUtils.java b/src/main/java/org/opentripplanner/middleware/utils/I18nUtils.java index c5c523844..087bce140 100644 --- a/src/main/java/org/opentripplanner/middleware/utils/I18nUtils.java +++ b/src/main/java/org/opentripplanner/middleware/utils/I18nUtils.java @@ -4,6 +4,7 @@ import org.opentripplanner.middleware.models.OtpUser; import java.util.Collection; +import java.util.IllformedLocaleException; import java.util.Locale; public class I18nUtils { @@ -31,4 +32,18 @@ public static Locale getOtpUserLocale(OtpUser user) { return Locale.forLanguageTag(user == null || user.preferredLocale == null ? "en-US" : user.preferredLocale); } + /** + * Attempt to create a {@link Locale} from the language tag. + */ + public static Locale getLocaleFromString(String languageTag) { + Locale locale = Locale.forLanguageTag("en-US"); + if (languageTag != null) { + try { + locale = Locale.forLanguageTag(languageTag); + } catch (NullPointerException | IllformedLocaleException e) { + // Give up and use the default! + } + } + return locale; + } } diff --git a/src/main/resources/Message.properties b/src/main/resources/Message.properties index 2f3a461b4..aae86669b 100644 --- a/src/main/resources/Message.properties +++ b/src/main/resources/Message.properties @@ -1,3 +1,9 @@ +ACCEPT_DEPENDENT_EMAIL_FOOTER = You are receiving this email because you have been selected to be a trusted companion. +ACCEPT_DEPENDENT_EMAIL_GREETING = %s would like you to be a trusted companion. +ACCEPT_DEPENDENT_EMAIL_LINK_TEXT = Accept trusted companion +ACCEPT_DEPENDENT_EMAIL_SUBJECT = Trusted companion request +ACCEPT_DEPENDENT_EMAIL_MANAGE = Manage settings +ACCEPT_DEPENDENT_ERROR = Unable to accept trusted companion. LABEL_AND_CONTENT = %s: %s SMS_STOP_NOTIFICATIONS = To stop receiving notifications, reply STOP. TRIP_EMAIL_SUBJECT = %s Notification diff --git a/src/main/resources/Message_fr.properties b/src/main/resources/Message_fr.properties index b64acce24..9011607a2 100644 --- a/src/main/resources/Message_fr.properties +++ b/src/main/resources/Message_fr.properties @@ -1,3 +1,9 @@ +ACCEPT_DEPENDENT_EMAIL_FOOTER = Vous recevez cet email parce qu'une personne vous a designé comme accompagnateur·trice. +ACCEPT_DEPENDENT_EMAIL_GREETING = %s voudrait vous avoir comme accompagnateur·trice. +ACCEPT_DEPENDENT_EMAIL_LINK_TEXT = Accepter la demande +ACCEPT_DEPENDENT_EMAIL_SUBJECT = Demande d'accompagnateur +ACCEPT_DEPENDENT_EMAIL_MANAGE = Gérez vos préférences +ACCEPT_DEPENDENT_ERROR = La demande d'accompagnateur n'a pas été reçue. LABEL_AND_CONTENT = %s\u00A0: %s SMS_STOP_NOTIFICATIONS = Pour arrêter ces notifications, envoyez STOP. TRIP_EMAIL_SUBJECT = Notification pour %s diff --git a/src/main/resources/env.schema.json b/src/main/resources/env.schema.json index fcc9f79f8..2c6828073 100644 --- a/src/main/resources/env.schema.json +++ b/src/main/resources/env.schema.json @@ -324,6 +324,11 @@ "examples": ["your-account-sid"], "description": "Twilio settings available at: https://twilio.com/user/account" }, + "TRUSTED_COMPANION_CONFIRMATION_PAGE_URL": { + "type": "string", + "examples": ["https://otp-server.example.com/trusted/confirmation"], + "description": "URL to the trusted companion confirmation page. This page should support handling an error URL parameter." + }, "TWILIO_AUTH_TOKEN": { "type": "string", "examples": ["your-auth-token"], diff --git a/src/main/resources/latest-spark-swagger-output.yaml b/src/main/resources/latest-spark-swagger-output.yaml index 0e0f1348c..7a9098fb2 100644 --- a/src/main/resources/latest-spark-swagger-output.yaml +++ b/src/main/resources/latest-spark-swagger-output.yaml @@ -1641,6 +1641,40 @@ paths: description: "An error occurred while performing the request. Contact an\ \ API administrator for more information." examples: {} + /api/secure/user/acceptdependent: + get: + tags: + - "api/secure/user" + description: "Accept a dependent request." + parameters: [] + responses: + "200": + description: "Successful operation" + examples: {} + schema: + $ref: "#/definitions/OtpUser" + responseSchema: + $ref: "#/definitions/OtpUser" + "400": + description: "The request was not formed properly (e.g., some required parameters\ + \ may be missing). See the details of the returned response to determine\ + \ the exact issue." + examples: {} + "401": + description: "The server was not able to authenticate the request. This\ + \ can happen if authentication headers are missing or malformed, or the\ + \ authentication server cannot be reached." + examples: {} + "403": + description: "The requesting user is not allowed to perform the request." + examples: {} + "404": + description: "The requested item was not found." + examples: {} + "500": + description: "An error occurred while performing the request. Contact an\ + \ API administrator for more information." + examples: {} /api/secure/user/{id}/verify_sms/{phoneNumber}: get: tags: @@ -2566,6 +2600,14 @@ definitions: $ref: "#/definitions/JourneyState" notifyAtLeadingInterval: type: "boolean" + primary: + $ref: "#/definitions/RelatedUser" + companion: + $ref: "#/definitions/RelatedUser" + observers: + type: "array" + items: + $ref: "#/definitions/RelatedUser" attemptsToGetMatchingItinerary: type: "integer" format: "int32" @@ -2832,6 +2874,21 @@ definitions: - "PAST_TRIP" hasRealtimeData: type: "boolean" + RelatedUser: + type: "object" + properties: + email: + type: "string" + status: + type: "string" + enum: + - "PENDING" + - "CONFIRMED" + - "INVALID" + acceptKey: + type: "string" + nickname: + type: "string" FareComponent: type: "object" properties: @@ -3118,6 +3175,16 @@ definitions: type: "boolean" applicationId: type: "string" + relatedUsers: + type: "array" + items: + $ref: "#/definitions/RelatedUser" + dependents: + type: "array" + items: + type: "string" + name: + type: "string" MobilityProfile: type: "object" properties: diff --git a/src/main/resources/templates/AcceptDependentHtml.ftl b/src/main/resources/templates/AcceptDependentHtml.ftl new file mode 100644 index 000000000..583bd5549 --- /dev/null +++ b/src/main/resources/templates/AcceptDependentHtml.ftl @@ -0,0 +1,15 @@ +<#ftl auto_esc=false> +<#include "OtpUserContainer.ftl"> + +<#-- + This is a template for an HTML email that gets sent when a dependent user is requesting a trusted companion. +--> + +<#macro EmailMain> +
+

${emailGreeting}

+

${acceptDependentLinkAnchorLabel}

+
+ + +<@HtmlEmail/> \ No newline at end of file diff --git a/src/main/resources/templates/AcceptDependentText.ftl b/src/main/resources/templates/AcceptDependentText.ftl new file mode 100644 index 000000000..248b91921 --- /dev/null +++ b/src/main/resources/templates/AcceptDependentText.ftl @@ -0,0 +1,11 @@ +<#-- + This is a template for a text email that gets sent when a dependent requests a trusted companion. + + Note: in plain text emails, all whitespace is preserved, + so the indentation of the notification content is intentionally not aligned + with the indentation of the macros. + +--> +${emailGreeting} + +${acceptDependentLinkLabelAndUrl} \ No newline at end of file diff --git a/src/test/java/org/opentripplanner/middleware/controllers/api/GetMonitoredTripsTest.java b/src/test/java/org/opentripplanner/middleware/controllers/api/MonitoredTripControllerTest.java similarity index 91% rename from src/test/java/org/opentripplanner/middleware/controllers/api/GetMonitoredTripsTest.java rename to src/test/java/org/opentripplanner/middleware/controllers/api/MonitoredTripControllerTest.java index 4585641c7..578270c74 100644 --- a/src/test/java/org/opentripplanner/middleware/controllers/api/GetMonitoredTripsTest.java +++ b/src/test/java/org/opentripplanner/middleware/controllers/api/MonitoredTripControllerTest.java @@ -1,6 +1,5 @@ package org.opentripplanner.middleware.controllers.api; -import com.auth0.exception.Auth0Exception; import com.auth0.json.mgmt.users.User; import com.mongodb.BasicDBObject; import org.eclipse.jetty.http.HttpMethod; @@ -19,11 +18,13 @@ import org.opentripplanner.middleware.persistence.Persistence; import org.opentripplanner.middleware.testutils.ApiTestUtils; import org.opentripplanner.middleware.testutils.OtpMiddlewareTestEnvironment; +import org.opentripplanner.middleware.testutils.OtpTestUtils; import org.opentripplanner.middleware.testutils.PersistenceTestUtils; import org.opentripplanner.middleware.utils.HttpResponseValues; import org.opentripplanner.middleware.utils.JsonUtils; import java.util.Date; +import java.util.HashMap; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; @@ -33,8 +34,10 @@ import static org.opentripplanner.middleware.auth.Auth0Connection.restoreDefaultAuthDisabled; import static org.opentripplanner.middleware.auth.Auth0Connection.setAuthDisabled; import static org.opentripplanner.middleware.testutils.ApiTestUtils.TEMP_AUTH0_USER_PASSWORD; +import static org.opentripplanner.middleware.testutils.ApiTestUtils.createAndAssignAuth0User; import static org.opentripplanner.middleware.testutils.ApiTestUtils.mockAuthenticatedGet; import static org.opentripplanner.middleware.testutils.ApiTestUtils.mockAuthenticatedRequest; +import static org.opentripplanner.middleware.testutils.PersistenceTestUtils.deleteOtpUser; /** * Tests to simulate getting trips as an Otp user with enhanced admin credentials. The following config parameters must @@ -54,7 +57,7 @@ * * Auth0 must be correctly configured as described here: https://auth0.com/docs/flows/call-your-api-using-resource-owner-password-flow */ -public class GetMonitoredTripsTest extends OtpMiddlewareTestEnvironment { +public class MonitoredTripControllerTest extends OtpMiddlewareTestEnvironment { private static AdminUser multiAdminUser; private static OtpUser soloOtpUser; private static OtpUser multiOtpUser; @@ -63,6 +66,7 @@ public class GetMonitoredTripsTest extends OtpMiddlewareTestEnvironment { private static final String UI_QUERY_PARAMS = "?fromPlace=fromplace%3A%3A28.556631%2C-81.411781&toPlace=toplace%3A%3A28.545925%2C-81.348609&date=2020-11-13&time=14%3A21&arriveBy=false&mode=WALK%2CBUS%2CRAIL&numItineraries=3"; private static final String DUMMY_STRING = "ABCDxyz"; + private static HashMap guardianHeaders; /** * Create Otp and Admin user accounts. Create Auth0 account for just the Otp users. If @@ -78,18 +82,14 @@ public static void setUp() { multiOtpUser = PersistenceTestUtils.createUser(multiUserEmail); multiAdminUser = PersistenceTestUtils.createAdminUser(multiUserEmail); try { - // Should use Auth0User.createNewAuth0User but this generates a random password preventing the mock headers - // from being able to use TEMP_AUTH0_USER_PASSWORD. - User auth0User = Auth0Users.createAuth0UserForEmail(soloOtpUser.email, TEMP_AUTH0_USER_PASSWORD); - soloOtpUser.auth0UserId = auth0User.getId(); - Persistence.otpUsers.replace(soloOtpUser.id, soloOtpUser); - auth0User = Auth0Users.createAuth0UserForEmail(multiUserEmail, TEMP_AUTH0_USER_PASSWORD); + createAndAssignAuth0User(soloOtpUser); + User auth0User = Auth0Users.createAuth0UserForEmail(multiUserEmail, TEMP_AUTH0_USER_PASSWORD); multiOtpUser.auth0UserId = auth0User.getId(); Persistence.otpUsers.replace(multiOtpUser.id, multiOtpUser); - // Use the same Auth0 user id as otpUser2 as the email address is the same. + // Use the same Auth0 user id as multiOtpUser as the email address is the same. multiAdminUser.auth0UserId = auth0User.getId(); Persistence.adminUsers.replace(multiAdminUser.id, multiAdminUser); - } catch (Auth0Exception e) { + } catch (Exception e) { throw new RuntimeException(e); } } @@ -101,12 +101,13 @@ public static void setUp() { public static void tearDown() { assumeTrue(IS_END_TO_END); restoreDefaultAuthDisabled(); - soloOtpUser = Persistence.otpUsers.getById(soloOtpUser.id); - if (soloOtpUser != null) soloOtpUser.delete(false); - multiOtpUser = Persistence.otpUsers.getById(multiOtpUser.id); - if (multiOtpUser != null) multiOtpUser.delete(false); multiAdminUser = Persistence.adminUsers.getById(multiAdminUser.id); if (multiAdminUser != null) multiAdminUser.delete(); + deleteOtpUser( + IS_END_TO_END, + soloOtpUser, + multiOtpUser + ); } @AfterEach diff --git a/src/test/java/org/opentripplanner/middleware/controllers/api/OtpUserControllerTest.java b/src/test/java/org/opentripplanner/middleware/controllers/api/OtpUserControllerTest.java index 9912ef7b6..1f62260db 100644 --- a/src/test/java/org/opentripplanner/middleware/controllers/api/OtpUserControllerTest.java +++ b/src/test/java/org/opentripplanner/middleware/controllers/api/OtpUserControllerTest.java @@ -10,32 +10,53 @@ import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; import org.opentripplanner.middleware.models.OtpUser; +import org.opentripplanner.middleware.models.RelatedUser; import org.opentripplanner.middleware.persistence.Persistence; import org.opentripplanner.middleware.testutils.ApiTestUtils; import org.opentripplanner.middleware.testutils.OtpMiddlewareTestEnvironment; +import org.opentripplanner.middleware.testutils.PersistenceTestUtils; +import org.opentripplanner.middleware.tripmonitor.TrustedCompanion; import org.opentripplanner.middleware.utils.HttpResponseValues; import org.opentripplanner.middleware.utils.JsonUtils; -import java.io.IOException; import java.util.Date; +import java.util.List; +import java.util.Locale; +import java.util.UUID; import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + import static org.opentripplanner.middleware.testutils.ApiTestUtils.getMockHeaders; +import static org.opentripplanner.middleware.testutils.ApiTestUtils.makeGetRequest; import static org.opentripplanner.middleware.testutils.ApiTestUtils.makeRequest; import static org.opentripplanner.middleware.testutils.ApiTestUtils.mockAuthenticatedGet; import static org.opentripplanner.middleware.auth.Auth0Connection.restoreDefaultAuthDisabled; import static org.opentripplanner.middleware.auth.Auth0Connection.setAuthDisabled; +import static org.opentripplanner.middleware.testutils.PersistenceTestUtils.deleteOtpUser; public class OtpUserControllerTest extends OtpMiddlewareTestEnvironment { private static final String INITIAL_PHONE_NUMBER = "+15555550222"; // Fake US 555 number. private static OtpUser otpUser; + private static OtpUser relatedUserOne; + private static OtpUser dependentUserOne; + private static OtpUser relatedUserTwo; + private static OtpUser dependentUserTwo; + private static OtpUser relatedUserThree; + private static OtpUser dependentUserThree; + private static OtpUser relatedUserFour; + private static OtpUser dependentUserFour; + private static final String nickname = "my-trusted-companion"; @BeforeAll - public static void setUp() throws IOException { - // Ensure auth is disabled. - setAuthDisabled(true); + public static void setUp() throws Exception { + assumeTrue(IS_END_TO_END); + // Set the overall auth to disabled. + setAuthDisabled(false); + // Create a persisted OTP user. otpUser = new OtpUser(); otpUser.email = ApiTestUtils.generateEmailAddress("test-otpusercont"); @@ -44,14 +65,31 @@ public static void setUp() throws IOException { otpUser.isPhoneNumberVerified = true; otpUser.smsConsentDate = new Date(); Persistence.otpUsers.create(otpUser); + + relatedUserOne = PersistenceTestUtils.createUser(ApiTestUtils.generateEmailAddress("related-user-one")); + dependentUserOne = PersistenceTestUtils.createUser(ApiTestUtils.generateEmailAddress("dependent-one")); + relatedUserTwo = PersistenceTestUtils.createUser(ApiTestUtils.generateEmailAddress("related-user-two")); + dependentUserTwo = PersistenceTestUtils.createUser(ApiTestUtils.generateEmailAddress("dependent-two")); + relatedUserThree = PersistenceTestUtils.createUser(ApiTestUtils.generateEmailAddress("related-user-three")); + dependentUserThree = PersistenceTestUtils.createUser(ApiTestUtils.generateEmailAddress("dependent-three")); + relatedUserFour = PersistenceTestUtils.createUser(ApiTestUtils.generateEmailAddress("related-user-four")); + dependentUserFour = PersistenceTestUtils.createUser(ApiTestUtils.generateEmailAddress("dependent-four")); } @AfterAll public static void tearDown() { - // Delete the users if they were not already deleted during the test script. - otpUser = Persistence.otpUsers.getById(otpUser.id); - // Delete OtpUser. No need to delete Auth0 user since one was never created above (auth is disabled). - if (otpUser != null) otpUser.delete(false); + deleteOtpUser( + IS_END_TO_END, + otpUser, + relatedUserOne, + relatedUserTwo, + relatedUserThree, + relatedUserFour, + dependentUserOne, + dependentUserTwo, + dependentUserThree, + dependentUserFour + ); // Restore original isAuthDisabled state. restoreDefaultAuthDisabled(); @@ -63,7 +101,8 @@ public static void tearDown() { */ @ParameterizedTest @MethodSource("createBadPhoneNumbers") - public void invalidNumbersShouldProduceBadRequest(String badNumber, int statusCode) throws Exception { + void invalidNumbersShouldProduceBadRequest(String badNumber, int statusCode) throws Exception { + setAuthDisabled(true); // 1. Request verification SMS. // The invalid number should fail the call. HttpResponseValues response = mockAuthenticatedGet( @@ -86,6 +125,7 @@ public void invalidNumbersShouldProduceBadRequest(String badNumber, int statusCo OtpUser otpUserWithPhone = JsonUtils.getPOJOFromJSON(otpUserWithPhoneRequest.responseBody, OtpUser.class); assertEquals(INITIAL_PHONE_NUMBER, otpUserWithPhone.phoneNumber); assertTrue(otpUserWithPhone.isPhoneNumberVerified); + setAuthDisabled(false); } private static Stream createBadPhoneNumbers() { @@ -100,7 +140,7 @@ private static Stream createBadPhoneNumbers() { */ @ParameterizedTest @MethodSource("createPhoneNumberTestCases") - public void isPhoneNumberValidE164(String number, boolean isValid) { + void isPhoneNumberValidE164(String number, boolean isValid) { assertEquals(isValid, OtpUserController.isPhoneNumberValidE164(number)); } @@ -136,4 +176,103 @@ void canPreserveSmsConsentDate() throws Exception { OtpUser updatedUser = Persistence.otpUsers.getById(otpUser.id); Assertions.assertEquals(otpUser.smsConsentDate, updatedUser.smsConsentDate); } + + @Test + void canAcceptDependentRequest() { + String acceptKey = UUID.randomUUID().toString(); + dependentUserOne.relatedUsers.add(new RelatedUser( + relatedUserOne.email, + RelatedUser.RelatedUserStatus.PENDING, + nickname, + acceptKey + )); + Persistence.otpUsers.replace(dependentUserOne.id, dependentUserOne); + + Locale locale = new Locale("en", "GB"); + String path = TrustedCompanion.getAcceptDependentEndPoint(acceptKey, locale); + makeGetRequest(path, null); + + relatedUserOne = Persistence.otpUsers.getById(relatedUserOne.id); + assertTrue(relatedUserOne.dependents.contains(dependentUserOne.id)); + + dependentUserOne = Persistence.otpUsers.getById(dependentUserOne.id); + List relatedUsers = dependentUserOne.relatedUsers; + relatedUsers + .stream() + .filter(user -> user.email.equals(relatedUserOne.email)) + .forEach(user -> assertEquals(RelatedUser.RelatedUserStatus.CONFIRMED, user.status)); + } + + @Test + void canInvalidateDependentOnDelete() { + relatedUserTwo.dependents.add(dependentUserTwo.id); + Persistence.otpUsers.replace(relatedUserTwo.id, relatedUserTwo); + dependentUserTwo.relatedUsers.add(new RelatedUser( + relatedUserTwo.email, + RelatedUser.RelatedUserStatus.CONFIRMED, + nickname + )); + Persistence.otpUsers.replace(dependentUserTwo.id, dependentUserTwo); + relatedUserTwo.delete(false); + dependentUserTwo = Persistence.otpUsers.getById(dependentUserTwo.id); + RelatedUser relatedUser = dependentUserTwo.relatedUsers.get(0); + assertEquals(RelatedUser.RelatedUserStatus.INVALID, relatedUser.status); + } + + @Test + void canRemoveRelatedUserOnDelete() { + relatedUserThree.dependents.add(dependentUserThree.id); + Persistence.otpUsers.replace(relatedUserThree.id, relatedUserThree); + dependentUserThree.relatedUsers.add(new RelatedUser( + relatedUserThree.email, + RelatedUser.RelatedUserStatus.CONFIRMED, + nickname + )); + Persistence.otpUsers.replace(dependentUserThree.id, dependentUserThree); + dependentUserThree.delete(false); + relatedUserThree = Persistence.otpUsers.getById(relatedUserThree.id); + assertFalse(relatedUserThree.dependents.contains(dependentUserThree.id)); + } + + /** + * Confirm that a user can be removed from a related users list, and importantly, the related user no longer lists + * the removed dependent. + */ + @Test + void canRemoveUserFromRelatedUsersList() throws Exception { + setAuthDisabled(true); + relatedUserFour.dependents.add(dependentUserFour.id); + Persistence.otpUsers.replace(relatedUserFour.id, relatedUserFour); + dependentUserFour.relatedUsers.add(new RelatedUser( + relatedUserFour.email, + RelatedUser.RelatedUserStatus.CONFIRMED, + nickname + )); + Persistence.otpUsers.replace(dependentUserFour.id, dependentUserFour); + + // Remove the first related user. + dependentUserFour.relatedUsers.clear(); + + // Add a new related user that should not be considered for integrity update. + dependentUserFour.relatedUsers.add(new RelatedUser( + relatedUserThree.email, + RelatedUser.RelatedUserStatus.PENDING, + nickname + )); + + makeRequest( + String.format("api/secure/user/%s", dependentUserFour.id), + JsonUtils.toJson(dependentUserFour), + getMockHeaders(dependentUserFour), + HttpMethod.PUT + ); + + dependentUserFour = Persistence.otpUsers.getById(dependentUserFour.id); + assertFalse(dependentUserFour.relatedUsers.stream().anyMatch(u -> u.email.equalsIgnoreCase(relatedUserFour.email))); + + relatedUserFour = Persistence.otpUsers.getById(relatedUserFour.id); + assertFalse(relatedUserFour.dependents.contains(dependentUserFour.id)); + + setAuthDisabled(false); + } } diff --git a/src/test/java/org/opentripplanner/middleware/testutils/ApiTestUtils.java b/src/test/java/org/opentripplanner/middleware/testutils/ApiTestUtils.java index 163caa5ab..c8250d2bf 100644 --- a/src/test/java/org/opentripplanner/middleware/testutils/ApiTestUtils.java +++ b/src/test/java/org/opentripplanner/middleware/testutils/ApiTestUtils.java @@ -1,5 +1,6 @@ package org.opentripplanner.middleware.testutils; +import com.auth0.json.mgmt.users.User; import org.eclipse.jetty.http.HttpMethod; import com.auth0.json.auth.TokenHolder; import org.eclipse.jetty.http.HttpStatus; @@ -197,4 +198,14 @@ public static String generateEmailAddress(String prefix) { return String.format("%s-%s@example.com", prefix, UUID.randomUUID().toString()); } + /** + * Should use Auth0User.createNewAuth0User but this generates a random password preventing the mock headers + * from being able to use TEMP_AUTH0_USER_PASSWORD. + */ + public static HashMap createAndAssignAuth0User(OtpUser user) throws Exception { + User auth0User = Auth0Users.createAuth0UserForEmail(user.email, TEMP_AUTH0_USER_PASSWORD); + user.auth0UserId = auth0User.getId(); + Persistence.otpUsers.replace(user.id, user); + return getMockHeaders(user); + } } diff --git a/src/test/java/org/opentripplanner/middleware/testutils/PersistenceTestUtils.java b/src/test/java/org/opentripplanner/middleware/testutils/PersistenceTestUtils.java index e87a75a6f..a221a7adf 100644 --- a/src/test/java/org/opentripplanner/middleware/testutils/PersistenceTestUtils.java +++ b/src/test/java/org/opentripplanner/middleware/testutils/PersistenceTestUtils.java @@ -267,4 +267,15 @@ static Itinerary createItinerary() { itinerary.legs = legs; return itinerary; } + + public static void deleteOtpUser(boolean isEndToEnd, OtpUser... otpUsers) { + for (OtpUser otpUser : otpUsers) { + if (otpUser != null) { + OtpUser user = Persistence.otpUsers.getById(otpUser.id); + if (user != null) { + user.delete(isEndToEnd); + } + } + } + } } diff --git a/src/test/java/org/opentripplanner/middleware/tripmonitor/TrustedCompanionTest.java b/src/test/java/org/opentripplanner/middleware/tripmonitor/TrustedCompanionTest.java new file mode 100644 index 000000000..5c6d73517 --- /dev/null +++ b/src/test/java/org/opentripplanner/middleware/tripmonitor/TrustedCompanionTest.java @@ -0,0 +1,51 @@ +package org.opentripplanner.middleware.tripmonitor; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.opentripplanner.middleware.models.OtpUser; +import org.opentripplanner.middleware.models.RelatedUser; +import org.opentripplanner.middleware.persistence.Persistence; +import org.opentripplanner.middleware.testutils.ApiTestUtils; +import org.opentripplanner.middleware.testutils.OtpMiddlewareTestEnvironment; +import org.opentripplanner.middleware.testutils.PersistenceTestUtils; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.opentripplanner.middleware.auth.Auth0Connection.setAuthDisabled; +import static org.opentripplanner.middleware.testutils.PersistenceTestUtils.deleteOtpUser; + +public class TrustedCompanionTest extends OtpMiddlewareTestEnvironment { + private static OtpUser relatedUserOne; + private static OtpUser dependentUserOne; + private static final String nickName = "my-trusted-companion"; + + @BeforeAll + public static void setUp() { + setAuthDisabled(false); + relatedUserOne = PersistenceTestUtils.createUser(ApiTestUtils.generateEmailAddress("related-user-one")); + dependentUserOne = PersistenceTestUtils.createUser(ApiTestUtils.generateEmailAddress("dependent-one")); + } + + @AfterAll + public static void tearDown() { + deleteOtpUser(IS_END_TO_END, + relatedUserOne, + dependentUserOne + ); + } + + @Test + void canManageAcceptDependentEmail() { + dependentUserOne.relatedUsers.add(new RelatedUser( + relatedUserOne.email, + RelatedUser.RelatedUserStatus.PENDING, + nickName, + UUID.randomUUID().toString() + )); + Persistence.otpUsers.replace(dependentUserOne.id, dependentUserOne); + TrustedCompanion.manageAcceptDependentEmail(dependentUserOne, true); + assertNotNull(dependentUserOne.relatedUsers.get(0).acceptKey); + } +} \ No newline at end of file