diff options
author | Ola Aunrønning <olaa@yahooinc.com> | 2022-10-27 14:47:25 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2022-10-27 14:47:25 +0200 |
commit | 30a93b4910c1de1a10ebccfa545d03e1deac8056 (patch) | |
tree | 48f239e59fd895064f653165652b8d111140d991 /controller-server | |
parent | 0cb3aa286942e998a598c390332466a432b6e956 (diff) | |
parent | c6903a0afe6244587ccf2ae345acaff3b55fb12b (diff) |
Merge pull request #24555 from vespa-engine/olaa/email-verification
Add email verification
Diffstat (limited to 'controller-server')
16 files changed, 504 insertions, 64 deletions
diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/Controller.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/Controller.java index af56666c6eb..22ce4db10e5 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/Controller.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/Controller.java @@ -86,6 +86,7 @@ public class Controller extends AbstractComponent { private final NotificationsDb notificationsDb; private final SupportAccessControl supportAccessControl; private final Notifier notifier; + private final MailVerifier mailVerifier; /** * Creates a controller @@ -126,6 +127,7 @@ public class Controller extends AbstractComponent { notifier = new Notifier(curator, serviceRegistry.zoneRegistry(), serviceRegistry.mailer(), flagSource); notificationsDb = new NotificationsDb(this); supportAccessControl = new SupportAccessControl(this); + mailVerifier = new MailVerifier(tenantController, serviceRegistry.mailer(), curator, clock); // Record the version of this controller curator().writeControllerVersion(this.hostname(), serviceRegistry.controllerVersion()); @@ -339,4 +341,8 @@ public class Controller extends AbstractComponent { public Notifier notifier() { return notifier; } + + public MailVerifier mailVerifier() { + return mailVerifier; + } } diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/MailVerifier.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/MailVerifier.java new file mode 100644 index 00000000000..a7f3a3ca3b2 --- /dev/null +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/MailVerifier.java @@ -0,0 +1,116 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.controller; + +import com.yahoo.config.provision.TenantName; +import com.yahoo.vespa.hosted.controller.api.integration.organization.Mailer; +import com.yahoo.vespa.hosted.controller.persistence.CuratorDb; +import com.yahoo.vespa.hosted.controller.tenant.CloudTenant; +import com.yahoo.vespa.hosted.controller.tenant.Tenant; +import com.yahoo.vespa.hosted.controller.tenant.TenantContacts; +import com.yahoo.vespa.hosted.controller.tenant.TenantInfo; +import com.yahoo.vespa.hosted.controller.tenant.PendingMailVerification; + +import java.time.Clock; +import java.time.Duration; +import java.util.Optional; +import java.util.UUID; + + +/** + * @author olaa + */ +public class MailVerifier { + + private final TenantController tenantController; + private final Mailer mailer; + private final CuratorDb curatorDb; + private final Clock clock; + private static final Duration VERIFICATION_DEADLINE = Duration.ofDays(7); + + public MailVerifier(TenantController tenantController, Mailer mailer, CuratorDb curatorDb, Clock clock) { + this.tenantController = tenantController; + this.mailer = mailer; + this.curatorDb = curatorDb; + this.clock = clock; + } + + public PendingMailVerification sendMailVerification(TenantName tenantName, String email, PendingMailVerification.MailType mailType) { + if (!email.contains("@")) { + throw new IllegalArgumentException("Invalid email address"); + } + + var verificationCode = UUID.randomUUID().toString(); + var verificationDeadline = clock.instant().plus(VERIFICATION_DEADLINE); + var pendingMailVerification = new PendingMailVerification(tenantName, email, verificationCode, verificationDeadline, mailType); + writePendingVerification(pendingMailVerification); + mailer.sendVerificationMail(pendingMailVerification); + return pendingMailVerification; + } + + public Optional<PendingMailVerification> resendMailVerification(TenantName tenantName, String email, PendingMailVerification.MailType mailType) { + var oldPendingVerification = curatorDb.listPendingMailVerifications() + .stream() + .filter(pendingMailVerification -> + pendingMailVerification.getMailAddress().equals(email) && + pendingMailVerification.getMailType().equals(mailType) && + pendingMailVerification.getTenantName().equals(tenantName) + ).findFirst(); + + if (oldPendingVerification.isEmpty()) + return Optional.empty(); + + try (var lock = curatorDb.lockPendingMailVerification(oldPendingVerification.get().getVerificationCode())) { + curatorDb.deletePendingMailVerification(oldPendingVerification.get()); + } + + return Optional.of(sendMailVerification(tenantName, email, mailType)); + } + + public boolean verifyMail(String verificationCode) { + return curatorDb.getPendingMailVerification(verificationCode) + .filter(pendingMailVerification -> pendingMailVerification.getVerificationDeadline().isAfter(clock.instant())) + .map(pendingMailVerification -> { + var tenant = requireCloudTenant(pendingMailVerification.getTenantName()); + var oldTenantInfo = tenant.info(); + var updatedTenantInfo = switch (pendingMailVerification.getMailType()) { + case NOTIFICATIONS -> withTenantContacts(oldTenantInfo, pendingMailVerification); + case TENANT_CONTACT -> oldTenantInfo.withContact(oldTenantInfo.contact() + .withEmail(oldTenantInfo.contact().email().withVerification(true))); + }; + + tenantController.lockOrThrow(tenant.name(), LockedTenant.Cloud.class, lockedTenant -> { + lockedTenant = lockedTenant.withInfo(updatedTenantInfo); + tenantController.store(lockedTenant); + }); + + try (var lock = curatorDb.lockPendingMailVerification(pendingMailVerification.getVerificationCode())) { + curatorDb.deletePendingMailVerification(pendingMailVerification); + } + return true; + }).orElse(false); + } + + private TenantInfo withTenantContacts(TenantInfo oldInfo, PendingMailVerification pendingMailVerification) { + var newContacts = oldInfo.contacts().ofType(TenantContacts.EmailContact.class) + .stream() + .map(contact -> { + if (pendingMailVerification.getMailAddress().equals(contact.email().getEmailAddress())) + return contact.withEmail(contact.email().withVerification(true)); + return contact; + }).toList(); + return oldInfo.withContacts(new TenantContacts(newContacts)); + } + + private void writePendingVerification(PendingMailVerification pendingMailVerification) { + try (var lock = curatorDb.lockPendingMailVerification(pendingMailVerification.getVerificationCode())) { + curatorDb.writePendingMailVerification(pendingMailVerification); + } + } + + private CloudTenant requireCloudTenant(TenantName tenantName) { + return tenantController.get(tenantName) + .filter(tenant -> tenant.type() == Tenant.Type.cloud) + .map(CloudTenant.class::cast) + .orElseThrow(() -> new IllegalStateException("Mail verification is only applicable for cloud tenants")); + } +} diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/notification/Notifier.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/notification/Notifier.java index f2c9d55b2a2..1074649ca7a 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/notification/Notifier.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/notification/Notifier.java @@ -110,7 +110,10 @@ public class Notifier { private void dispatch(Notification notification, Collection<TenantContacts.EmailContact> contacts) { try { var content = formatter.format(notification); - mailer.send(mailOf(content, contacts.stream().map(c -> c.email()).collect(Collectors.toList()))); + mailer.send(mailOf(content, contacts.stream() + .filter(c -> c.email().isVerified()) + .map(c -> c.email().getEmailAddress()) + .collect(Collectors.toList()))); } catch (MailerException e) { log.log(Level.SEVERE, "Failed sending email", e); } catch (MissingOptionalException e) { diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/CuratorDb.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/CuratorDb.java index 0e8f1648765..d582937cb0b 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/CuratorDb.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/CuratorDb.java @@ -34,6 +34,7 @@ import com.yahoo.vespa.hosted.controller.routing.RoutingPolicy; import com.yahoo.vespa.hosted.controller.routing.RoutingStatus; import com.yahoo.vespa.hosted.controller.routing.ZoneRoutingPolicy; import com.yahoo.vespa.hosted.controller.support.access.SupportAccess; +import com.yahoo.vespa.hosted.controller.tenant.PendingMailVerification; import com.yahoo.vespa.hosted.controller.tenant.Tenant; import com.yahoo.vespa.hosted.controller.versions.OsVersionStatus; import com.yahoo.vespa.hosted.controller.versions.OsVersionTarget; @@ -94,6 +95,7 @@ public class CuratorDb { private static final Path changeRequestsRoot = root.append("changeRequests"); private static final Path notificationsRoot = root.append("notifications"); private static final Path supportAccessRoot = root.append("supportAccess"); + private static final Path mailVerificationRoot = root.append("mailVerification"); private final NodeVersionSerializer nodeVersionSerializer = new NodeVersionSerializer(); private final VersionStatusSerializer versionStatusSerializer = new VersionStatusSerializer(nodeVersionSerializer); @@ -231,6 +233,10 @@ public class CuratorDb { return curator.lock(lockRoot.append("deploymentRetriggerQueue"), defaultLockTimeout); } + public Mutex lockPendingMailVerification(String verificationCode) { + return curator.lock(lockRoot.append("pendingMailVerification").append(verificationCode), defaultLockTimeout); + } + // -------------- Helpers ------------------------------------------ /** Try locking with a low timeout, meaning it is OK to fail lock acquisition. @@ -655,6 +661,28 @@ public class CuratorDb { curator.set(deploymentRetriggerPath(), asJson(retriggerEntrySerializer.toSlime(retriggerEntries))); } + // -------------- Pending mail verification ------------------------------- + + public Optional<PendingMailVerification> getPendingMailVerification(String verificationCode) { + return readSlime(mailVerificationPath(verificationCode)).map(MailVerificationSerializer::fromSlime); + } + + public List<PendingMailVerification> listPendingMailVerifications() { + return curator.getChildren(mailVerificationRoot) + .stream() + .map(this::getPendingMailVerification) + .flatMap(Optional::stream) + .collect(Collectors.toList()); + } + + public void writePendingMailVerification(PendingMailVerification pendingMailVerification) { + curator.set(mailVerificationPath(pendingMailVerification.getVerificationCode()), asJson(MailVerificationSerializer.toSlime(pendingMailVerification))); + } + + public void deletePendingMailVerification(PendingMailVerification pendingMailVerification) { + curator.delete(mailVerificationPath(pendingMailVerification.getVerificationCode())); + } + // -------------- Paths --------------------------------------------------- private static Path upgradesPerMinutePath() { @@ -755,4 +783,8 @@ public class CuratorDb { return root.append("deploymentRetriggerQueue"); } + private static Path mailVerificationPath(String verificationCode) { + return mailVerificationRoot.append(verificationCode); + } + } diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/MailVerificationSerializer.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/MailVerificationSerializer.java new file mode 100644 index 00000000000..6910d5c21c0 --- /dev/null +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/MailVerificationSerializer.java @@ -0,0 +1,53 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.controller.persistence; + +import com.yahoo.config.provision.TenantName; +import com.yahoo.slime.Cursor; +import com.yahoo.slime.Inspector; +import com.yahoo.slime.Slime; +import com.yahoo.vespa.hosted.controller.tenant.PendingMailVerification; + +import java.time.Instant; + +/** + * @author olaa + */ +public class MailVerificationSerializer { + + private static final String tenantField = "tenant"; + private static final String audiencesField = "audiences"; + private static final String emailField = "email"; + private static final String emailTypeField = "emailType"; + private static final String emailVerificationCodeField = "emailVerificationCode"; + private static final String emailVerificationDeadlineField = "emailVerificationDeadline"; + private static final String rolesField = "roles"; + + public static Slime toSlime(PendingMailVerification pendingMailVerification) { + var slime = new Slime(); + var object = slime.setObject(); + toSlime(pendingMailVerification, object); + return slime; + } + + public static void toSlime(PendingMailVerification pendingMailVerification, Cursor object) { + object.setString(tenantField, pendingMailVerification.getTenantName().value()); + object.setString(emailVerificationCodeField, pendingMailVerification.getVerificationCode()); + object.setString(emailField, pendingMailVerification.getMailAddress()); + object.setLong(emailVerificationDeadlineField, pendingMailVerification.getVerificationDeadline().toEpochMilli()); + object.setString(emailTypeField, pendingMailVerification.getMailType().name()); + } + + public static PendingMailVerification fromSlime(Slime slime) { + return fromSlime(slime.get()); + } + + public static PendingMailVerification fromSlime(Inspector inspector) { + var tenant = TenantName.from(inspector.field(tenantField).asString()); + var address = inspector.field(emailField).asString(); + var verificationCode = inspector.field(emailVerificationCodeField).asString(); + var deadline = Instant.ofEpochMilli(inspector.field(emailVerificationDeadlineField).asLong()); + var type = PendingMailVerification.MailType.valueOf(inspector.field(emailTypeField).asString()); + return new PendingMailVerification(tenant, address, verificationCode, deadline, type); + } + +} diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/TenantSerializer.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/TenantSerializer.java index fc7cafe4c89..b6d0155b6ab 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/TenantSerializer.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/persistence/TenantSerializer.java @@ -21,6 +21,7 @@ import com.yahoo.vespa.hosted.controller.tenant.ArchiveAccess; import com.yahoo.vespa.hosted.controller.tenant.AthenzTenant; import com.yahoo.vespa.hosted.controller.tenant.CloudTenant; import com.yahoo.vespa.hosted.controller.tenant.DeletedTenant; +import com.yahoo.vespa.hosted.controller.tenant.Email; import com.yahoo.vespa.hosted.controller.tenant.LastLoginInfo; import com.yahoo.vespa.hosted.controller.tenant.Tenant; import com.yahoo.vespa.hosted.controller.tenant.TenantAddress; @@ -234,7 +235,7 @@ public class TenantSerializer { .withWebsite(infoObject.field("website").asString()) .withContact(TenantContact.from( infoObject.field("contactName").asString(), - infoObject.field("contactEmail").asString())) + new Email(infoObject.field("contactEmail").asString(), asBoolOrTrue(infoObject.field("contactEmailVerified"))))) .withAddress(tenantInfoAddressFromSlime(infoObject.field("address"))) .withBilling(tenantInfoBillingContactFromSlime(infoObject.field("billingContact"))) .withContacts(tenantContactsFrom(infoObject.field("contacts"))); @@ -253,7 +254,7 @@ public class TenantSerializer { return TenantBilling.empty() .withContact(TenantContact.from( billingObject.field("name").asString(), - billingObject.field("email").asString(), + new Email(billingObject.field("email").asString(), true), billingObject.field("phone").asString())) .withAddress(tenantInfoAddressFromSlime(billingObject.field("address"))); } @@ -283,7 +284,8 @@ public class TenantSerializer { infoCursor.setString("email", info.email()); infoCursor.setString("website", info.website()); infoCursor.setString("contactName", info.contact().name()); - infoCursor.setString("contactEmail", info.contact().email()); + infoCursor.setString("contactEmail", info.contact().email().getEmailAddress()); + infoCursor.setBool("contactEmailVerified", info.contact().email().isVerified()); toSlime(info.address(), infoCursor); toSlime(info.billingContact(), infoCursor); toSlime(info.contacts(), infoCursor); @@ -305,7 +307,7 @@ public class TenantSerializer { Cursor addressCursor = parentCursor.setObject("billingContact"); addressCursor.setString("name", billingContact.contact().name()); - addressCursor.setString("email", billingContact.contact().email()); + addressCursor.setString("email", billingContact.contact().email().getEmailAddress()); addressCursor.setString("phone", billingContact.contact().phone()); toSlime(billingContact.address(), addressCursor); } @@ -386,7 +388,8 @@ public class TenantSerializer { switch (contact.type()) { case EMAIL: var email = (TenantContacts.EmailContact) contact; - data.setString("email", email.email()); + data.setString("email", email.email().getEmailAddress()); + data.setBool("emailVerified", email.email().isVerified()); return; default: throw new IllegalArgumentException("Serialization for contact type not implemented: " + contact.type()); @@ -401,7 +404,8 @@ public class TenantSerializer { .collect(Collectors.toUnmodifiableList()); switch (type) { case EMAIL: - return new TenantContacts.EmailContact(audiences, inspector.field("data").field("email").asString()); + var isVerified = asBoolOrTrue(inspector.field("data").field("emailVerified")); + return new TenantContacts.EmailContact(audiences, new Email(inspector.field("data").field("email").asString(), isVerified)); default: throw new IllegalArgumentException("Serialization for contact type not implemented: " + type); } @@ -460,4 +464,8 @@ public class TenantSerializer { } } + private boolean asBoolOrTrue(Inspector inspector) { + return !inspector.valid() || inspector.asBool(); + } + } diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiHandler.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiHandler.java index 161e4b30dd9..23f945e02be 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiHandler.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiHandler.java @@ -111,7 +111,9 @@ import com.yahoo.vespa.hosted.controller.tenant.ArchiveAccess; import com.yahoo.vespa.hosted.controller.tenant.AthenzTenant; import com.yahoo.vespa.hosted.controller.tenant.CloudTenant; import com.yahoo.vespa.hosted.controller.tenant.DeletedTenant; +import com.yahoo.vespa.hosted.controller.tenant.Email; import com.yahoo.vespa.hosted.controller.tenant.LastLoginInfo; +import com.yahoo.vespa.hosted.controller.tenant.PendingMailVerification; import com.yahoo.vespa.hosted.controller.tenant.Tenant; import com.yahoo.vespa.hosted.controller.tenant.TenantAddress; import com.yahoo.vespa.hosted.controller.tenant.TenantBilling; @@ -301,6 +303,7 @@ public class ApplicationApiHandler extends AuditLoggingRequestHandler { if (path.matches("/application/v4/tenant/{tenant}/info/profile")) return withCloudTenant(path.get("tenant"), request, this::putTenantInfoProfile); if (path.matches("/application/v4/tenant/{tenant}/info/billing")) return withCloudTenant(path.get("tenant"), request, this::putTenantInfoBilling); if (path.matches("/application/v4/tenant/{tenant}/info/contacts")) return withCloudTenant(path.get("tenant"), request, this::putTenantInfoContacts); + if (path.matches("/application/v4/tenant/{tenant}/info/resend-mail-verification")) return withCloudTenant(path.get("tenant"), request, this::resendEmailVerification); if (path.matches("/application/v4/tenant/{tenant}/archive-access")) return allowAwsArchiveAccess(path.get("tenant"), request); // TODO(enygaard, 2022-05-25) Remove when no longer used by console if (path.matches("/application/v4/tenant/{tenant}/archive-access/aws")) return allowAwsArchiveAccess(path.get("tenant"), request); if (path.matches("/application/v4/tenant/{tenant}/archive-access/gcp")) return allowGcpArchiveAccess(path.get("tenant"), request); @@ -522,7 +525,8 @@ public class ApplicationApiHandler extends AuditLoggingRequestHandler { infoCursor.setString("email", info.email()); infoCursor.setString("website", info.website()); infoCursor.setString("contactName", info.contact().name()); - infoCursor.setString("contactEmail", info.contact().email()); + infoCursor.setString("contactEmail", info.contact().email().getEmailAddress()); + infoCursor.setBool("contactEmailVerified", info.contact().email().isVerified()); toSlime(info.address(), infoCursor); toSlime(info.billingContact(), infoCursor); toSlime(info.contacts(), infoCursor); @@ -539,7 +543,8 @@ public class ApplicationApiHandler extends AuditLoggingRequestHandler { if (!info.isEmpty()) { var contact = root.setObject("contact"); contact.setString("name", info.contact().name()); - contact.setString("email", info.contact().email()); + contact.setString("email", info.contact().email().getEmailAddress()); + contact.setBool("emailVerified", info.contact().email().isVerified()); var tenant = root.setObject("tenant"); tenant.setString("company", info.name()); @@ -560,9 +565,17 @@ public class ApplicationApiHandler extends AuditLoggingRequestHandler { private SlimeJsonResponse putTenantInfoProfile(CloudTenant cloudTenant, Inspector inspector) { var info = cloudTenant.info(); + var mergedEmail = optional("email", inspector.field("contact")) + .filter(address -> !address.equals(info.contact().email().getEmailAddress())) + .map(address -> { + controller.mailVerifier().sendMailVerification(cloudTenant.name(), address, PendingMailVerification.MailType.TENANT_CONTACT); + return new Email(address, false); + }) + .orElse(info.contact().email()); + var mergedContact = TenantContact.empty() .withName(getString(inspector.field("contact").field("name"), info.contact().name())) - .withEmail(getString(inspector.field("contact").field("email"), info.contact().email())); + .withEmail(mergedEmail); var mergedAddress = updateTenantInfoAddress(inspector.field("address"), info.address()); @@ -592,7 +605,7 @@ public class ApplicationApiHandler extends AuditLoggingRequestHandler { var contact = root.setObject("contact"); contact.setString("name", billingContact.contact().name()); - contact.setString("email", billingContact.contact().email()); + contact.setString("email", billingContact.contact().email().getEmailAddress()); contact.setString("phone", billingContact.contact().phone()); toSlime(billingContact.address(), root); // will create "address" on the parent @@ -606,7 +619,7 @@ public class ApplicationApiHandler extends AuditLoggingRequestHandler { var contact = info.billingContact().contact(); var address = info.billingContact().address(); - var mergedContact = updateTenantInfoContact(inspector.field("contact"), contact); + var mergedContact = updateTenantInfoContact(inspector.field("contact"), cloudTenant.name(), contact, false); var mergedAddress = updateTenantInfoAddress(inspector.field("address"), info.billingContact().address()); var mergedBilling = info.billingContact() @@ -633,7 +646,7 @@ public class ApplicationApiHandler extends AuditLoggingRequestHandler { private SlimeJsonResponse putTenantInfoContacts(CloudTenant cloudTenant, Inspector inspector) { var mergedInfo = cloudTenant.info() - .withContacts(updateTenantInfoContacts(inspector.field("contacts"), cloudTenant.info().contacts())); + .withContacts(updateTenantInfoContacts(inspector.field("contacts"), cloudTenant.name(), cloudTenant.info().contacts())); // Store changes controller.tenants().lockOrThrow(cloudTenant.name(), LockedTenant.Cloud.class, lockedTenant -> { @@ -649,10 +662,10 @@ public class ApplicationApiHandler extends AuditLoggingRequestHandler { if (mergedInfo.contact().name().isBlank()) { throw new IllegalArgumentException("'contactName' cannot be empty"); } - if (mergedInfo.contact().email().isBlank()) { + if (mergedInfo.contact().email().getEmailAddress().isBlank()) { throw new IllegalArgumentException("'contactEmail' cannot be empty"); } - if (! mergedInfo.contact().email().contains("@")) { + if (! mergedInfo.contact().email().getEmailAddress().contains("@")) { // email address validation is notoriously hard - we should probably just try to send a // verification email to this address. checking for @ is a simple best-effort. throw new IllegalArgumentException("'contactEmail' needs to be an email address"); @@ -682,7 +695,7 @@ public class ApplicationApiHandler extends AuditLoggingRequestHandler { Cursor addressCursor = parentCursor.setObject("billingContact"); addressCursor.setString("name", billingContact.contact().name()); - addressCursor.setString("email", billingContact.contact().email()); + addressCursor.setString("email", billingContact.contact().email().getEmailAddress()); addressCursor.setString("phone", billingContact.contact().phone()); toSlime(billingContact.address(), addressCursor); } @@ -696,7 +709,8 @@ public class ApplicationApiHandler extends AuditLoggingRequestHandler { switch (contact.type()) { case EMAIL: var email = (TenantContacts.EmailContact) contact; - contactCursor.setString("email", email.email()); + contactCursor.setString("email", email.email().getEmailAddress()); + contactCursor.setBool("emailVerified", email.email().isVerified()); return; default: throw new IllegalArgumentException("Serialization for contact type not implemented: " + contact.type()); @@ -737,9 +751,17 @@ public class ApplicationApiHandler extends AuditLoggingRequestHandler { // Merge info from request with the existing info Inspector insp = toSlime(request.getData()).get(); + var mergedEmail = optional("contactEmail", insp) + .filter(address -> !address.equals(oldInfo.contact().email().getEmailAddress())) + .map(address -> { + controller.mailVerifier().sendMailVerification(tenant.name(), address, PendingMailVerification.MailType.TENANT_CONTACT); + return new Email(address, false); + }) + .orElse(oldInfo.contact().email()); + TenantContact mergedContact = TenantContact.empty() .withName(getString(insp.field("contactName"), oldInfo.contact().name())) - .withEmail(getString(insp.field("contactEmail"), oldInfo.contact().email())); + .withEmail(mergedEmail); TenantInfo mergedInfo = TenantInfo.empty() .withName(getString(insp.field("name"), oldInfo.name())) @@ -747,8 +769,8 @@ public class ApplicationApiHandler extends AuditLoggingRequestHandler { .withWebsite(getString(insp.field("website"), oldInfo.website())) .withContact(mergedContact) .withAddress(updateTenantInfoAddress(insp.field("address"), oldInfo.address())) - .withBilling(updateTenantInfoBillingContact(insp.field("billingContact"), oldInfo.billingContact())) - .withContacts(updateTenantInfoContacts(insp.field("contacts"), oldInfo.contacts())); + .withBilling(updateTenantInfoBillingContact(insp.field("billingContact"), tenant.name(), oldInfo.billingContact())) + .withContacts(updateTenantInfoContacts(insp.field("contacts"), tenant.name(), oldInfo.contacts())); validateMergedTenantInfo(mergedInfo); @@ -782,32 +804,34 @@ public class ApplicationApiHandler extends AuditLoggingRequestHandler { throw new IllegalArgumentException("All address fields must be set"); } - private TenantContact updateTenantInfoContact(Inspector insp, TenantContact oldContact) { + private TenantContact updateTenantInfoContact(Inspector insp, TenantName tenantName, TenantContact oldContact, boolean isBillingContact) { if (!insp.valid()) return oldContact; - String email = getString(insp.field("email"), oldContact.email()); - - if (!email.isBlank() && !email.contains("@")) { - // email address validation is notoriously hard - we should probably just try to send a - // verification email to this address. checking for @ is a simple best-effort. - throw new IllegalArgumentException("'email' needs to be an email address"); - } + var mergedEmail = optional("email", insp) + .filter(address -> !address.equals(oldContact.email().getEmailAddress())) + .map(address -> { + if (isBillingContact) + return new Email(address, true); + controller.mailVerifier().sendMailVerification(tenantName, address, PendingMailVerification.MailType.TENANT_CONTACT); + return new Email(address, false); + }) + .orElse(oldContact.email()); return TenantContact.empty() .withName(getString(insp.field("name"), oldContact.name())) - .withEmail(getString(insp.field("email"), oldContact.email())) + .withEmail(mergedEmail) .withPhone(getString(insp.field("phone"), oldContact.phone())); } - private TenantBilling updateTenantInfoBillingContact(Inspector insp, TenantBilling oldContact) { + private TenantBilling updateTenantInfoBillingContact(Inspector insp, TenantName tenantName, TenantBilling oldContact) { if (!insp.valid()) return oldContact; return TenantBilling.empty() - .withContact(updateTenantInfoContact(insp, oldContact.contact())) + .withContact(updateTenantInfoContact(insp, tenantName, oldContact.contact(), true)) .withAddress(updateTenantInfoAddress(insp.field("address"), oldContact.address())); } - private TenantContacts updateTenantInfoContacts(Inspector insp, TenantContacts oldContacts) { + private TenantContacts updateTenantInfoContacts(Inspector insp, TenantName tenantName, TenantContacts oldContacts) { if (!insp.valid()) return oldContacts; List<TenantContacts.EmailContact> contacts = SlimeUtils.entriesStream(insp).map(inspector -> { @@ -816,11 +840,16 @@ public class ApplicationApiHandler extends AuditLoggingRequestHandler { .map(audience -> fromAudience(audience.asString())) .toList(); - if (!email.contains("@")) { - throw new IllegalArgumentException("'email' needs to be an email address"); - } - - return new TenantContacts.EmailContact(audiences, email); + // If contact exists, update audience. Otherwise, create new unverified contact + return oldContacts.ofType(TenantContacts.EmailContact.class) + .stream() + .filter(contact -> contact.email().getEmailAddress().equals(email)) + .findAny() + .map(emailContact -> new TenantContacts.EmailContact(audiences, emailContact.email())) + .orElseGet(() -> { + controller.mailVerifier().sendMailVerification(tenantName, email, PendingMailVerification.MailType.NOTIFICATIONS); + return new TenantContacts.EmailContact(audiences, new Email(email, false)); + }); }).toList(); return new TenantContacts(contacts); @@ -1499,6 +1528,21 @@ public class ApplicationApiHandler extends AuditLoggingRequestHandler { return new MessageResponse(type.jobName() + " for " + id + " resumed"); } + private SlimeJsonResponse resendEmailVerification(CloudTenant tenant, Inspector inspector) { + var mail = mandatory("mail", inspector).asString(); + var type = mandatory("mailType", inspector).asString(); + + var mailType = switch (type) { + case "contact" -> PendingMailVerification.MailType.TENANT_CONTACT; + case "notifications" -> PendingMailVerification.MailType.NOTIFICATIONS; + default -> throw new IllegalArgumentException("Unknown mail type " + type); + }; + + var pendingVerification = controller.mailVerifier().resendMailVerification(tenant.name(), mail, mailType); + return pendingVerification.isPresent() ? new MessageResponse("Re-sent verification mail to " + mail) : + ErrorResponse.notFoundError("No pending mail verification found for " + mail); + } + private void toSlime(Cursor object, Application application, HttpRequest request) { object.setString("tenant", application.id().tenant().value()); object.setString("application", application.id().application().value()); @@ -2012,7 +2056,7 @@ public class ApplicationApiHandler extends AuditLoggingRequestHandler { User user = getAttribute(request, User.ATTRIBUTE_NAME, User.class); TenantInfo info = controller.tenants().require(tenant, CloudTenant.class) .info() - .withContact(TenantContact.from(user.name(), user.email())); + .withContact(TenantContact.from(user.name(), new Email(user.email(), true))); // Store changes controller.tenants().lockOrThrow(tenant, LockedTenant.Cloud.class, lockedTenant -> { lockedTenant = lockedTenant.withInfo(info); diff --git a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/user/UserApiHandler.java b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/user/UserApiHandler.java index 3491ef4ab03..7a8ef1d4ee6 100644 --- a/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/user/UserApiHandler.java +++ b/controller-server/src/main/java/com/yahoo/vespa/hosted/controller/restapi/user/UserApiHandler.java @@ -112,6 +112,7 @@ public class UserApiHandler extends ThreadedHttpRequestHandler { private HttpResponse handlePOST(Path path, HttpRequest request) { if (path.matches("/user/v1/tenant/{tenant}")) return addTenantRoleMember(path.get("tenant"), request); + if (path.matches("/user/v1/email/verify")) return verifyEmail(request); return ErrorResponse.notFoundError(Text.format("No '%s' handler at '%s'", request.getMethod(), request.getUri().getPath())); @@ -311,6 +312,16 @@ public class UserApiHandler extends ThreadedHttpRequestHandler { return new MessageResponse(user + " is now a member of " + roles.stream().map(Role::toString).collect(Collectors.joining(", "))); } + private HttpResponse verifyEmail(HttpRequest request) { + var inspector = bodyInspector(request); + var verificationCode = require("verificationCode", Inspector::asString, inspector); + var verified = controller.mailVerifier().verifyMail(verificationCode); + + if (verified) + return new MessageResponse("Email with verification code " + verificationCode + " has been verified"); + return ErrorResponse.notFoundError("No pending email verification with code " + verificationCode + " found"); + } + private HttpResponse removeTenantRoleMember(String tenantName, HttpRequest request) { Inspector requestObject = bodyInspector(request); var tenant = TenantName.from(tenantName); diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/MailVerifierTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/MailVerifierTest.java new file mode 100644 index 00000000000..873ab435444 --- /dev/null +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/MailVerifierTest.java @@ -0,0 +1,102 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.controller; + +import com.yahoo.config.provision.SystemName; +import com.yahoo.config.provision.TenantName; +import com.yahoo.vespa.hosted.controller.api.integration.stubs.MockMailer; +import com.yahoo.vespa.hosted.controller.tenant.CloudTenant; +import com.yahoo.vespa.hosted.controller.tenant.Email; +import com.yahoo.vespa.hosted.controller.tenant.PendingMailVerification; +import com.yahoo.vespa.hosted.controller.tenant.Tenant; +import com.yahoo.vespa.hosted.controller.tenant.TenantContacts; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + + +/** + * @author olaa + */ +class MailVerifierTest { + + private final ControllerTester tester = new ControllerTester(SystemName.Public); + private final MockMailer mailer = tester.serviceRegistry().mailer(); + private final MailVerifier mailVerifier = new MailVerifier(tester.controller().tenants(), mailer, tester.curator(), tester.clock()); + + private static final TenantName tenantName = TenantName.from("scoober"); + private static final String mail = "unverified@bar.com"; + private static final List<TenantContacts.Audience> audiences = List.of(TenantContacts.Audience.NOTIFICATIONS, TenantContacts.Audience.TENANT); + + @BeforeEach + public void setup() { + tester.createTenant(tenantName.value(), Tenant.Type.cloud); + + tester.controller().tenants().lockOrThrow(tenantName, LockedTenant.Cloud.class, lockedTenant -> { + var contacts = List.of( + new TenantContacts.EmailContact(audiences, new Email("verified@bar.com", true)), + new TenantContacts.EmailContact(audiences, new Email(mail, false)), + new TenantContacts.EmailContact(audiences, new Email("another-unverified@bar.com", false)) + ); + lockedTenant = lockedTenant.withInfo(lockedTenant.get().info().withContacts(new TenantContacts(contacts))); + tester.controller().tenants().store(lockedTenant); + }); + } + + @Test + public void test_new_mail_verification() { + mailVerifier.sendMailVerification(tenantName, mail, PendingMailVerification.MailType.NOTIFICATIONS); + + // Verify mail is sent + var expectedMail = "message"; + assertEquals(1, mailer.inbox(mail).size()); + assertEquals(expectedMail, mailer.inbox(mail).get(0).message()); + + // Verify ZK data is updated + var writtenMailVerification = tester.curator().listPendingMailVerifications().get(0); + assertEquals(PendingMailVerification.MailType.NOTIFICATIONS, writtenMailVerification.getMailType()); + assertEquals(tenantName, writtenMailVerification.getTenantName()); + assertEquals(tester.clock().instant().plus(Duration.ofDays(7)), writtenMailVerification.getVerificationDeadline()); + assertEquals(mail, writtenMailVerification.getMailAddress()); + + // Mail verification is no-op if deadline has passed + tester.clock().advance(Duration.ofDays(14)); + assertFalse(mailVerifier.verifyMail(writtenMailVerification.getVerificationCode())); + assertFalse(tester.curator().listPendingMailVerifications().isEmpty()); + + // Mail is verified + tester.clock().retreat(Duration.ofDays(14)); + mailVerifier.verifyMail(writtenMailVerification.getVerificationCode()); + assertTrue(tester.curator().listPendingMailVerifications().isEmpty()); + var tenant = tester.controller().tenants().require(tenantName, CloudTenant.class); + var expectedContacts = List.of( + new TenantContacts.EmailContact(audiences, new Email("verified@bar.com", true)), + new TenantContacts.EmailContact(audiences, new Email(mail, true)), + new TenantContacts.EmailContact(audiences, new Email("another-unverified@bar.com", false)) + ); + assertEquals(expectedContacts, tenant.info().contacts().all()); + } + + @Test + public void resending_verification_deletes_old_one() { + var pendingMailVerification = mailVerifier.sendMailVerification(tenantName, mail, PendingMailVerification.MailType.NOTIFICATIONS); + var tenant = tester.controller().tenants().require(tenantName, CloudTenant.class); + + // Unknown mail is no-op + var resentVerification = mailVerifier.resendMailVerification(tenantName, "unknown-mail", PendingMailVerification.MailType.NOTIFICATIONS); + assertTrue(resentVerification.isEmpty()); + assertTrue(tester.curator().getPendingMailVerification(pendingMailVerification.getVerificationCode()).isPresent()); + + // Verification mail is re-sent, old data is replaced + resentVerification = mailVerifier.resendMailVerification(tenantName, mail, PendingMailVerification.MailType.NOTIFICATIONS); + assertTrue(resentVerification.isPresent()); + assertTrue(tester.curator().getPendingMailVerification(pendingMailVerification.getVerificationCode()).isEmpty()); + assertTrue(tester.curator().getPendingMailVerification(resentVerification.get().getVerificationCode()).isPresent()); + } + +}
\ No newline at end of file diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/notification/NotificationsDbTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/notification/NotificationsDbTest.java index 4c1344650f8..852d4847b7e 100644 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/notification/NotificationsDbTest.java +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/notification/NotificationsDbTest.java @@ -22,6 +22,7 @@ import com.yahoo.vespa.hosted.controller.integration.ZoneRegistryMock; import com.yahoo.vespa.hosted.controller.persistence.MockCuratorDb; import com.yahoo.vespa.hosted.controller.tenant.ArchiveAccess; import com.yahoo.vespa.hosted.controller.tenant.CloudTenant; +import com.yahoo.vespa.hosted.controller.tenant.Email; import com.yahoo.vespa.hosted.controller.tenant.LastLoginInfo; import com.yahoo.vespa.hosted.controller.tenant.TenantContacts; import com.yahoo.vespa.hosted.controller.tenant.TenantInfo; @@ -49,7 +50,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; public class NotificationsDbTest { private static final TenantName tenant = TenantName.from("tenant1"); - private static final String email = "user1@example.com"; + private static final Email email = new Email("user1@example.com", true); private static final CloudTenant cloudTenant = new CloudTenant(tenant, Instant.now(), LastLoginInfo.EMPTY, @@ -111,19 +112,19 @@ public class NotificationsDbTest { ; var a = notifications.get(0); notificationsDb.setNotification(a.source(), a.type(), a.level(), a.messages()); - assertEquals(0, mailer.inbox(email).size()); + assertEquals(0, mailer.inbox(email.getEmailAddress()).size()); // Replace the 3rd notification. but don't change source or type notificationsDb.setNotification(notification1.source(), notification1.type(), notification1.level(), notification1.messages()); - assertEquals(0, mailer.inbox(email).size()); + assertEquals(0, mailer.inbox(email.getEmailAddress()).size()); // Notification for a new app, add without replacement notificationsDb.setNotification(notification2.source(), notification2.type(), notification2.level(), notification2.messages()); - assertEquals(1, mailer.inbox(email).size()); + assertEquals(1, mailer.inbox(email.getEmailAddress()).size()); // Notification for new type on existing app notificationsDb.setNotification(notification3.source(), notification3.type(), notification3.level(), notification3.messages()); - assertEquals(2, mailer.inbox(email).size()); + assertEquals(2, mailer.inbox(email.getEmailAddress()).size()); } @Test @@ -157,19 +158,19 @@ public class NotificationsDbTest { // No metrics, no new notification notificationsDb.setDeploymentMetricsNotifications(deploymentId, List.of()); - assertEquals(0, mailer.inbox(email).size()); + assertEquals(0, mailer.inbox(email.getEmailAddress()).size()); // Metrics that contain none of the feed block metrics does not create new notification notificationsDb.setDeploymentMetricsNotifications(deploymentId, List.of(clusterMetrics("cluster1", null, null, null, null, Map.of()))); - assertEquals(0, mailer.inbox(email).size()); + assertEquals(0, mailer.inbox(email.getEmailAddress()).size()); // One resource is at warning notificationsDb.setDeploymentMetricsNotifications(deploymentId, List.of(clusterMetrics("cluster1", 0.88, 0.9, 0.3, 0.5, Map.of()))); - assertEquals(1, mailer.inbox(email).size()); + assertEquals(1, mailer.inbox(email.getEmailAddress()).size()); // One resource over the limit notificationsDb.setDeploymentMetricsNotifications(deploymentId, List.of(clusterMetrics("cluster1", 0.95, 0.9, 0.3, 0.5, Map.of()))); - assertEquals(2, mailer.inbox(email).size()); + assertEquals(2, mailer.inbox(email.getEmailAddress()).size()); } @Test diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/notification/NotifierTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/notification/NotifierTest.java index 7c07192d633..c0501206494 100644 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/notification/NotifierTest.java +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/notification/NotifierTest.java @@ -13,6 +13,7 @@ import com.yahoo.vespa.hosted.controller.integration.ZoneRegistryMock; import com.yahoo.vespa.hosted.controller.persistence.MockCuratorDb; import com.yahoo.vespa.hosted.controller.tenant.ArchiveAccess; import com.yahoo.vespa.hosted.controller.tenant.CloudTenant; +import com.yahoo.vespa.hosted.controller.tenant.Email; import com.yahoo.vespa.hosted.controller.tenant.LastLoginInfo; import com.yahoo.vespa.hosted.controller.tenant.TenantContacts; import com.yahoo.vespa.hosted.controller.tenant.TenantInfo; @@ -28,7 +29,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; public class NotifierTest { private static final TenantName tenant = TenantName.from("tenant1"); - private static final String email = "user1@example.com"; + private static final Email email = new Email("user1@example.com", true); private static final CloudTenant cloudTenant = new CloudTenant(tenant, Instant.now(), @@ -63,8 +64,8 @@ public class NotifierTest { List.of("test package has production tests, but no production tests are declared in deployment.xml", "see https://docs.vespa.ai/en/testing.html for details on how to write system tests for Vespa")); notifier.dispatch(notification); - assertEquals(1, mailer.inbox(email).size()); - var mail = mailer.inbox(email).get(0); + assertEquals(1, mailer.inbox(email.getEmailAddress()).size()); + var mail = mailer.inbox(email.getEmailAddress()).get(0); assertEquals("[WARNING] Test package Vespa Notification for tenant1.default.default", mail.subject()); assertEquals(""" diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/persistence/MailVerificationSerializerTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/persistence/MailVerificationSerializerTest.java new file mode 100644 index 00000000000..69c6e13ba62 --- /dev/null +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/persistence/MailVerificationSerializerTest.java @@ -0,0 +1,31 @@ +// Copyright Yahoo. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. +package com.yahoo.vespa.hosted.controller.persistence; + +import com.yahoo.config.provision.TenantName; +import com.yahoo.vespa.hosted.controller.tenant.PendingMailVerification; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * @author olaa + */ +public class MailVerificationSerializerTest { + + @Test + public void test_serialization() { + var original = new PendingMailVerification(TenantName.from("test-tenant"), + "email@mycomp.any", + "xyz-123", + Instant.now().truncatedTo(ChronoUnit.MILLIS), + PendingMailVerification.MailType.TENANT_CONTACT + ); + + var serialized = MailVerificationSerializer.toSlime(original); + var deserialized = MailVerificationSerializer.fromSlime(serialized); + assertEquals(original, deserialized); + } +}
\ No newline at end of file diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/persistence/TenantSerializerTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/persistence/TenantSerializerTest.java index 636620acf07..5144c5cb7b4 100644 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/persistence/TenantSerializerTest.java +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/persistence/TenantSerializerTest.java @@ -17,6 +17,7 @@ import com.yahoo.vespa.hosted.controller.tenant.ArchiveAccess; import com.yahoo.vespa.hosted.controller.tenant.AthenzTenant; import com.yahoo.vespa.hosted.controller.tenant.CloudTenant; import com.yahoo.vespa.hosted.controller.tenant.DeletedTenant; +import com.yahoo.vespa.hosted.controller.tenant.Email; import com.yahoo.vespa.hosted.controller.tenant.LastLoginInfo; import com.yahoo.vespa.hosted.controller.tenant.TenantAddress; import com.yahoo.vespa.hosted.controller.tenant.TenantBilling; @@ -190,7 +191,7 @@ public class TenantSerializerTest { Slime slime = new Slime(); Cursor parentObject = slime.setObject(); serializer.toSlime(partialInfo, parentObject); - assertEquals("{\"info\":{\"name\":\"\",\"email\":\"\",\"website\":\"\",\"contactName\":\"\",\"contactEmail\":\"\",\"address\":{\"addressLines\":\"\",\"postalCodeOrZip\":\"\",\"city\":\"Hønefoss\",\"stateRegionProvince\":\"\",\"country\":\"\"}}}", slime.toString()); + assertEquals("{\"info\":{\"name\":\"\",\"email\":\"\",\"website\":\"\",\"contactName\":\"\",\"contactEmail\":\"\",\"contactEmailVerified\":true,\"address\":{\"addressLines\":\"\",\"postalCodeOrZip\":\"\",\"city\":\"Hønefoss\",\"stateRegionProvince\":\"\",\"country\":\"\"}}}", slime.toString()); } @Test @@ -199,7 +200,7 @@ public class TenantSerializerTest { .withName("My Company") .withEmail("email@mycomp.any") .withWebsite("http://mycomp.any") - .withContact(TenantContact.from("My Name", "ceo@mycomp.any")) + .withContact(TenantContact.from("My Name", new Email("ceo@mycomp.any", true))) .withAddress(TenantAddress.empty() .withCity("Hønefoss") .withAddress("Riperbakken 2") @@ -207,7 +208,7 @@ public class TenantSerializerTest { .withCode("3510") .withRegion("Viken")) .withBilling(TenantBilling.empty() - .withContact(TenantContact.from("Thomas The Tank Engine", "thomas@sodor.com", "NA")) + .withContact(TenantContact.from("Thomas The Tank Engine", new Email("ceo@mycomp.any", true), "NA")) .withAddress(TenantAddress.empty() .withCity("Suddery") .withCountry("Sodor") @@ -226,8 +227,8 @@ public class TenantSerializerTest { void cloud_tenant_with_tenant_info_contacts() { TenantInfo tenantInfo = TenantInfo.empty() .withContacts(new TenantContacts(List.of( - new TenantContacts.EmailContact(List.of(TenantContacts.Audience.TENANT), "email1@email.com"), - new TenantContacts.EmailContact(List.of(TenantContacts.Audience.TENANT, TenantContacts.Audience.NOTIFICATIONS), "email2@email.com")))); + new TenantContacts.EmailContact(List.of(TenantContacts.Audience.TENANT), new Email("email1@email.com", true)), + new TenantContacts.EmailContact(List.of(TenantContacts.Audience.TENANT, TenantContacts.Audience.NOTIFICATIONS), new Email("email2@email.com", true))))); Slime slime = new Slime(); Cursor parentCursor = slime.setObject(); serializer.toSlime(tenantInfo, parentCursor); diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiCloudTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiCloudTest.java index ab671be23eb..4a816ebeee9 100644 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiCloudTest.java +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/application/ApplicationApiCloudTest.java @@ -90,7 +90,7 @@ public class ApplicationApiCloudTest extends ControllerContainerCloudTest { .roles(Set.of(Role.administrator(tenantName))); tester.assertResponse(updateRequest, "{\"message\":\"Tenant info updated\"}", 200); - tester.assertResponse(request, "{\"contact\":{\"name\":\"Some Name\",\"email\":\"foo@example.com\"},\"tenant\":{\"company\":\"\",\"website\":\"https://example.com/\"}}", 200); + tester.assertResponse(request, "{\"contact\":{\"name\":\"Some Name\",\"email\":\"foo@example.com\",\"emailVerified\":false},\"tenant\":{\"company\":\"\",\"website\":\"https://example.com/\"}}", 200); } @Test @@ -117,7 +117,7 @@ public class ApplicationApiCloudTest extends ControllerContainerCloudTest { tester.assertResponse(request, "{\"contacts\":[]}", 200); - var fullContacts = "{\"contacts\":[{\"audiences\":[\"tenant\"],\"email\":\"contact1@example.com\"},{\"audiences\":[\"notifications\"],\"email\":\"contact2@example.com\"},{\"audiences\":[\"tenant\",\"notifications\"],\"email\":\"contact3@example.com\"}]}"; + var fullContacts = "{\"contacts\":[{\"audiences\":[\"tenant\"],\"email\":\"contact1@example.com\",\"emailVerified\":false},{\"audiences\":[\"notifications\"],\"email\":\"contact2@example.com\",\"emailVerified\":false},{\"audiences\":[\"tenant\",\"notifications\"],\"email\":\"contact3@example.com\",\"emailVerified\":false}]}"; var updateRequest = request("/application/v4/tenant/scoober/info/contacts", PUT) .data(fullContacts) .roles(Set.of(Role.administrator(tenantName))); @@ -147,12 +147,12 @@ public class ApplicationApiCloudTest extends ControllerContainerCloudTest { tester.assertResponse(postPartialContacts, "{\"message\":\"Tenant info updated\"}", 200); // Read back the updated info - tester.assertResponse(infoRequest, "{\"name\":\"\",\"email\":\"\",\"website\":\"\",\"contactName\":\"newName\",\"contactEmail\":\"foo@example.com\",\"billingContact\":{\"name\":\"billingName\",\"email\":\"\",\"phone\":\"\"},\"contacts\":[{\"audiences\":[\"tenant\"],\"email\":\"contact1@example.com\"}]}", 200); + tester.assertResponse(infoRequest, "{\"name\":\"\",\"email\":\"\",\"website\":\"\",\"contactName\":\"newName\",\"contactEmail\":\"foo@example.com\",\"contactEmailVerified\":false,\"billingContact\":{\"name\":\"billingName\",\"email\":\"\",\"phone\":\"\"},\"contacts\":[{\"audiences\":[\"tenant\"],\"email\":\"contact1@example.com\",\"emailVerified\":false}]}", 200); String fullAddress = "{\"addressLines\":\"addressLines\",\"postalCodeOrZip\":\"postalCodeOrZip\",\"city\":\"city\",\"stateRegionProvince\":\"stateRegionProvince\",\"country\":\"country\"}"; String fullBillingContact = "{\"name\":\"name\",\"email\":\"foo@example\",\"phone\":\"phone\",\"address\":" + fullAddress + "}"; - String fullContacts = "[{\"audiences\":[\"tenant\"],\"email\":\"contact1@example.com\"},{\"audiences\":[\"notifications\"],\"email\":\"contact2@example.com\"},{\"audiences\":[\"tenant\",\"notifications\"],\"email\":\"contact3@example.com\"}]"; - String fullInfo = "{\"name\":\"name\",\"email\":\"foo@example\",\"website\":\"https://yahoo.com\",\"contactName\":\"contactName\",\"contactEmail\":\"contact@example.com\",\"address\":" + fullAddress + ",\"billingContact\":" + fullBillingContact + ",\"contacts\":" + fullContacts + "}"; + String fullContacts = "[{\"audiences\":[\"tenant\"],\"email\":\"contact1@example.com\",\"emailVerified\":false},{\"audiences\":[\"notifications\"],\"email\":\"contact2@example.com\",\"emailVerified\":false},{\"audiences\":[\"tenant\",\"notifications\"],\"email\":\"contact3@example.com\",\"emailVerified\":false}]"; + String fullInfo = "{\"name\":\"name\",\"email\":\"foo@example\",\"website\":\"https://yahoo.com\",\"contactName\":\"contactName\",\"contactEmail\":\"contact@example.com\",\"contactEmailVerified\":false,\"address\":" + fullAddress + ",\"billingContact\":" + fullBillingContact + ",\"contacts\":" + fullContacts + "}"; // Now set all fields var postFull = @@ -163,6 +163,20 @@ public class ApplicationApiCloudTest extends ControllerContainerCloudTest { // Now compare the updated info with the full info we sent tester.assertResponse(infoRequest, fullInfo, 200); + + var invalidBody = "{\"mail\":\"contact1@example.com\", \"mailType\":\"blurb\"}"; + var resendMailRequest = + request("/application/v4/tenant/scoober/info/resend-mail-verification", PUT) + .data(invalidBody) + .roles(Set.of(Role.administrator(tenantName))); + tester.assertResponse(resendMailRequest, "{\"error-code\":\"BAD_REQUEST\",\"message\":\"Unknown mail type blurb\"}", 400); + + var resendMailBody = "{\"mail\":\"contact1@example.com\", \"mailType\":\"notifications\"}"; + resendMailRequest = + request("/application/v4/tenant/scoober/info/resend-mail-verification", PUT) + .data(resendMailBody) + .roles(Set.of(Role.administrator(tenantName))); + tester.assertResponse(resendMailRequest, "{\"message\":\"Re-sent verification mail to contact1@example.com\"}", 200); } @Test @@ -185,13 +199,13 @@ public class ApplicationApiCloudTest extends ControllerContainerCloudTest { var missingEmailResponse = request("/application/v4/tenant/scoober/info", PUT) .data(partialInfoMissingEmail) .roles(Set.of(Role.administrator(tenantName))); - tester.assertResponse(missingEmailResponse, "{\"error-code\":\"BAD_REQUEST\",\"message\":\"'contactEmail' cannot be empty\"}", 400); + tester.assertResponse(missingEmailResponse, "{\"error-code\":\"BAD_REQUEST\",\"message\":\"Invalid email address\"}", 400); var partialInfoBadEmail = "{\"contactName\": \"Scoober Rentals Inc.\", \"contactEmail\": \"somethingweird\"}"; var badEmailResponse = request("/application/v4/tenant/scoober/info", PUT) .data(partialInfoBadEmail) .roles(Set.of(Role.administrator(tenantName))); - tester.assertResponse(badEmailResponse, "{\"error-code\":\"BAD_REQUEST\",\"message\":\"'contactEmail' needs to be an email address\"}", 400); + tester.assertResponse(badEmailResponse, "{\"error-code\":\"BAD_REQUEST\",\"message\":\"Invalid email address\"}", 400); var invalidWebsite = "{\"contactName\": \"Scoober Rentals Inc.\", \"contactEmail\": \"email@scoober.com\", \"website\": \"scoober\" }"; var badWebsiteResponse = request("/application/v4/tenant/scoober/info", PUT) @@ -231,7 +245,7 @@ public class ApplicationApiCloudTest extends ControllerContainerCloudTest { var contactsWithInvalidEmailResponse = request("/application/v4/tenant/scoober/info", PUT) .data(contactsWithInvalidEmail) .roles(Set.of(Role.administrator(tenantName))); - tester.assertResponse(contactsWithInvalidEmailResponse, "{\"error-code\":\"BAD_REQUEST\",\"message\":\"'email' needs to be an email address\"}", 400); + tester.assertResponse(contactsWithInvalidEmailResponse, "{\"error-code\":\"BAD_REQUEST\",\"message\":\"Invalid email address\"}", 400); // duplicate contact is not allowed var contactsWithDuplicateEmail = "{\"contacts\": [{\"audiences\": [\"tenant\"],\"email\": \"contact1@email.com\"}, {\"audiences\": [\"tenant\"],\"email\": \"contact1@email.com\"}]}"; diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/UserApiTest.java b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/UserApiTest.java index b573940d150..075e001655f 100644 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/UserApiTest.java +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/UserApiTest.java @@ -10,11 +10,11 @@ import com.yahoo.vespa.flags.PermanentFlags; import com.yahoo.vespa.hosted.controller.ControllerTester; import com.yahoo.vespa.hosted.controller.api.integration.billing.PlanId; import com.yahoo.jdisc.http.filter.security.misc.User; -import com.yahoo.vespa.hosted.controller.api.integration.stubs.MockUserManagement; import com.yahoo.vespa.hosted.controller.api.integration.user.UserId; import com.yahoo.vespa.hosted.controller.api.role.Role; import com.yahoo.vespa.hosted.controller.restapi.ContainerTester; import com.yahoo.vespa.hosted.controller.restapi.ControllerContainerCloudTest; +import com.yahoo.vespa.hosted.controller.tenant.PendingMailVerification; import com.yahoo.vespa.hosted.controller.tenant.Tenant; import org.junit.jupiter.api.Test; @@ -321,4 +321,20 @@ public class UserApiTest extends ControllerContainerCloudTest { } } + + @Test + public void verifyMail() { + var tester = new ContainerTester(container, responseFiles); + var controller = new ControllerTester(tester); + controller.createTenant("tenant1", Tenant.Type.cloud); + var pendingMailVerification = tester.controller().mailVerifier().sendMailVerification(TenantName.from("tenant1"), "foo@bar.com", PendingMailVerification.MailType.NOTIFICATIONS); + + tester.assertResponse(request("/user/v1/email/verify", POST) + .data("{\"verificationCode\":\"blurb\"}"), + "{\"error-code\":\"NOT_FOUND\",\"message\":\"No pending email verification with code blurb found\"}", 404); + + tester.assertResponse(request("/user/v1/email/verify", POST) + .data("{\"verificationCode\":\"" + pendingMailVerification.getVerificationCode() + "\"}"), + "{\"message\":\"Email with verification code " + pendingMailVerification.getVerificationCode() + " has been verified\"}", 200); + } } diff --git a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/responses/tenant-info-after-created.json b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/responses/tenant-info-after-created.json index 2a33f35545c..6702eff8dde 100644 --- a/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/responses/tenant-info-after-created.json +++ b/controller-server/src/test/java/com/yahoo/vespa/hosted/controller/restapi/user/responses/tenant-info-after-created.json @@ -4,5 +4,6 @@ "website": "", "contactName": "administrator", "contactEmail": "administrator@tenant", + "contactEmailVerified": true, "contacts": [ ] } |