From 16eef3588238c46bb69ab36a7b9fd922ef9f47af Mon Sep 17 00:00:00 2001 From: YongJunXi Date: Tue, 14 Jul 2026 15:55:05 +0800 Subject: [PATCH 01/14] Create magic link entity --- .../java/teammates/common/util/Const.java | 1 + .../teammates/storage/entity/MagicLink.java | 188 ++++++++++++++++++ 2 files changed, 189 insertions(+) create mode 100644 src/main/java/teammates/storage/entity/MagicLink.java diff --git a/src/main/java/teammates/common/util/Const.java b/src/main/java/teammates/common/util/Const.java index 705bf51338c..e4b0fe9694e 100644 --- a/src/main/java/teammates/common/util/Const.java +++ b/src/main/java/teammates/common/util/Const.java @@ -37,6 +37,7 @@ public final class Const { public static final Duration FEEDBACK_SESSION_EVENT_EMAIL_LOOKBACK_WINDOW = Duration.ofDays(2); public static final Duration LOGS_RETENTION_PERIOD = Duration.ofDays(30); public static final Duration COOKIE_VALIDITY_PERIOD = Duration.ofDays(7); + public static final Duration MAGIC_LINK_VALIDITY_PERIOD = Duration.ofMinutes(15); public static final int SEARCH_QUERY_SIZE_LIMIT = 50; diff --git a/src/main/java/teammates/storage/entity/MagicLink.java b/src/main/java/teammates/storage/entity/MagicLink.java new file mode 100644 index 00000000000..9bbe4afed16 --- /dev/null +++ b/src/main/java/teammates/storage/entity/MagicLink.java @@ -0,0 +1,188 @@ +package teammates.storage.entity; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +import org.hibernate.annotations.NaturalId; +import org.hibernate.annotations.UpdateTimestamp; + +import teammates.common.util.Const; +import teammates.common.util.FieldValidator; +import teammates.common.util.SanitizationHelper; + +/** + * Represents a passwordless login magic link. + */ +@Entity +@Table(name = "MagicLinks") +public class MagicLink extends BaseEntity { + + @Id + private UUID id; + + @NaturalId + @Column(nullable = false, unique = true) + private String email; + + @Column(nullable = false, unique = true) + private String tokenHash; + + @Column(nullable = false) + private Instant expiresAt; + + private Instant usedAt; + + @Column(nullable = false) + private boolean revoked; + + @UpdateTimestamp + private Instant updatedAt; + + protected MagicLink() { + // required by Hibernate + } + + public MagicLink(String email, String tokenHash, Instant now) { + this.setId(UUID.randomUUID()); + this.setEmail(email); + this.setTokenHash(tokenHash); + this.setExpiresAt(now.plus(Const.MAGIC_LINK_VALIDITY_PERIOD)); + this.setUsedAt(null); + this.setRevoked(false); + } + + @Override + public List getInvalidityInfo() { + List errors = new ArrayList<>(); + + addNonEmptyError(FieldValidator.getInvalidityInfoForEmail(email), errors); + + return errors; + } + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = SanitizationHelper.sanitizeEmail(email); + } + + public String getTokenHash() { + return tokenHash; + } + + public void setTokenHash(String tokenHash) { + this.tokenHash = SanitizationHelper.sanitizeTextField(tokenHash); + } + + public Instant getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(Instant expiresAt) { + this.expiresAt = expiresAt; + } + + public Instant getUsedAt() { + return usedAt; + } + + public void setUsedAt(Instant usedAt) { + this.usedAt = usedAt; + } + + public boolean isRevoked() { + return revoked; + } + + public void setRevoked(boolean revoked) { + this.revoked = revoked; + } + + public Instant getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(Instant updatedAt) { + this.updatedAt = updatedAt; + } + + /** + * Returns true if the magic link expires at or before {@code now}. + */ + public boolean isExpired(Instant now) { + return !expiresAt.isAfter(now); + } + + /** + * Returns true if the magic link has already been used. + */ + public boolean isUsed() { + return usedAt != null; + } + + /** + * Returns true if the magic link can still be used. + */ + public boolean isUsable(Instant now) { + return !isUsed() && !isRevoked() && !isExpired(now); + } + + /** + * Marks the magic link as used if it has not already been used. + */ + public void markUsed(Instant now) { + if (isUsed()) { + return; + } + this.usedAt = now; + } + + /** + * Revokes the magic link. + */ + public void revoke() { + this.revoked = true; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + + if (!(o instanceof MagicLink other)) { + return false; + } + + return getId() != null && getId().equals(other.getId()); + } + + @Override + public int hashCode() { + return getClass().hashCode(); + } + + @Override + public String toString() { + return "MagicLink [id=" + id + ", email=" + email + ", expiresAt=" + expiresAt + + ", usedAt=" + usedAt + ", revoked=" + revoked + + ", createdAt=" + getCreatedAt() + ", updatedAt=" + updatedAt + "]"; + } +} From 1a09a723a4ed12828cbdbd2f5c089d3afc65467a Mon Sep 17 00:00:00 2001 From: YongJunXi Date: Tue, 14 Jul 2026 16:29:50 +0800 Subject: [PATCH 02/14] Change validity period --- src/main/java/teammates/common/util/Const.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/teammates/common/util/Const.java b/src/main/java/teammates/common/util/Const.java index e4b0fe9694e..6ecef483600 100644 --- a/src/main/java/teammates/common/util/Const.java +++ b/src/main/java/teammates/common/util/Const.java @@ -37,7 +37,7 @@ public final class Const { public static final Duration FEEDBACK_SESSION_EVENT_EMAIL_LOOKBACK_WINDOW = Duration.ofDays(2); public static final Duration LOGS_RETENTION_PERIOD = Duration.ofDays(30); public static final Duration COOKIE_VALIDITY_PERIOD = Duration.ofDays(7); - public static final Duration MAGIC_LINK_VALIDITY_PERIOD = Duration.ofMinutes(15); + public static final Duration MAGIC_LINK_VALIDITY_PERIOD = Duration.ofHours(1); public static final int SEARCH_QUERY_SIZE_LIMIT = 50; From bd2343a0fa9f350786dec240b1b608316b83a0e8 Mon Sep 17 00:00:00 2001 From: YongJunXi Date: Tue, 14 Jul 2026 16:39:01 +0800 Subject: [PATCH 03/14] Remove fields --- .../teammates/storage/entity/MagicLink.java | 50 +------------------ 1 file changed, 1 insertion(+), 49 deletions(-) diff --git a/src/main/java/teammates/storage/entity/MagicLink.java b/src/main/java/teammates/storage/entity/MagicLink.java index 9bbe4afed16..ae77ded7ee9 100644 --- a/src/main/java/teammates/storage/entity/MagicLink.java +++ b/src/main/java/teammates/storage/entity/MagicLink.java @@ -37,11 +37,6 @@ public class MagicLink extends BaseEntity { @Column(nullable = false) private Instant expiresAt; - private Instant usedAt; - - @Column(nullable = false) - private boolean revoked; - @UpdateTimestamp private Instant updatedAt; @@ -54,8 +49,6 @@ public MagicLink(String email, String tokenHash, Instant now) { this.setEmail(email); this.setTokenHash(tokenHash); this.setExpiresAt(now.plus(Const.MAGIC_LINK_VALIDITY_PERIOD)); - this.setUsedAt(null); - this.setRevoked(false); } @Override @@ -99,22 +92,6 @@ public void setExpiresAt(Instant expiresAt) { this.expiresAt = expiresAt; } - public Instant getUsedAt() { - return usedAt; - } - - public void setUsedAt(Instant usedAt) { - this.usedAt = usedAt; - } - - public boolean isRevoked() { - return revoked; - } - - public void setRevoked(boolean revoked) { - this.revoked = revoked; - } - public Instant getUpdatedAt() { return updatedAt; } @@ -130,35 +107,11 @@ public boolean isExpired(Instant now) { return !expiresAt.isAfter(now); } - /** - * Returns true if the magic link has already been used. - */ - public boolean isUsed() { - return usedAt != null; - } - /** * Returns true if the magic link can still be used. */ public boolean isUsable(Instant now) { - return !isUsed() && !isRevoked() && !isExpired(now); - } - - /** - * Marks the magic link as used if it has not already been used. - */ - public void markUsed(Instant now) { - if (isUsed()) { - return; - } - this.usedAt = now; - } - - /** - * Revokes the magic link. - */ - public void revoke() { - this.revoked = true; + return !isExpired(now); } @Override @@ -182,7 +135,6 @@ public int hashCode() { @Override public String toString() { return "MagicLink [id=" + id + ", email=" + email + ", expiresAt=" + expiresAt - + ", usedAt=" + usedAt + ", revoked=" + revoked + ", createdAt=" + getCreatedAt() + ", updatedAt=" + updatedAt + "]"; } } From 137972dc130e3e5092ae4886a51774669b08b295 Mon Sep 17 00:00:00 2001 From: YongJunXi Date: Wed, 15 Jul 2026 10:59:53 +0800 Subject: [PATCH 04/14] Create migration file --- .../migrations/20260715-create-magic-link.xml | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 src/main/resources/db/changelog/migrations/20260715-create-magic-link.xml diff --git a/src/main/resources/db/changelog/migrations/20260715-create-magic-link.xml b/src/main/resources/db/changelog/migrations/20260715-create-magic-link.xml new file mode 100644 index 00000000000..4b611ac063b --- /dev/null +++ b/src/main/resources/db/changelog/migrations/20260715-create-magic-link.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + From 28ef972c744846835a0402a8eb85ef151ae01eeb Mon Sep 17 00:00:00 2001 From: YongJunXi Date: Wed, 15 Jul 2026 11:02:17 +0800 Subject: [PATCH 05/14] Change expiry time --- src/main/java/teammates/common/util/Const.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/teammates/common/util/Const.java b/src/main/java/teammates/common/util/Const.java index 6ecef483600..815a52b0b75 100644 --- a/src/main/java/teammates/common/util/Const.java +++ b/src/main/java/teammates/common/util/Const.java @@ -37,7 +37,7 @@ public final class Const { public static final Duration FEEDBACK_SESSION_EVENT_EMAIL_LOOKBACK_WINDOW = Duration.ofDays(2); public static final Duration LOGS_RETENTION_PERIOD = Duration.ofDays(30); public static final Duration COOKIE_VALIDITY_PERIOD = Duration.ofDays(7); - public static final Duration MAGIC_LINK_VALIDITY_PERIOD = Duration.ofHours(1); + public static final Duration MAGIC_LINK_VALIDITY_PERIOD = Duration.ofMinutes(5); public static final int SEARCH_QUERY_SIZE_LIMIT = 50; From 1690f13acba4eeeaa8e8a31de2c0296a60ae6796 Mon Sep 17 00:00:00 2001 From: YongJunXi Date: Wed, 15 Jul 2026 10:50:20 +0800 Subject: [PATCH 06/14] Add storage layer --- .../teammates/common/util/HibernateUtil.java | 2 + .../teammates/storage/api/MagicLinksDb.java | 68 +++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 src/main/java/teammates/storage/api/MagicLinksDb.java diff --git a/src/main/java/teammates/common/util/HibernateUtil.java b/src/main/java/teammates/common/util/HibernateUtil.java index 6ca66cb56e7..86ba8d33570 100644 --- a/src/main/java/teammates/common/util/HibernateUtil.java +++ b/src/main/java/teammates/common/util/HibernateUtil.java @@ -30,6 +30,7 @@ import teammates.storage.entity.InstructorCoursePrivilege; import teammates.storage.entity.InstructorSectionPrivilege; import teammates.storage.entity.InstructorSessionPrivilege; +import teammates.storage.entity.MagicLink; import teammates.storage.entity.Notification; import teammates.storage.entity.ReadNotification; import teammates.storage.entity.ResponseInstructorComment; @@ -67,6 +68,7 @@ public final class HibernateUtil { Course.class, FeedbackSession.class, Account.class, + MagicLink.class, Notification.class, ReadNotification.class, User.class, diff --git a/src/main/java/teammates/storage/api/MagicLinksDb.java b/src/main/java/teammates/storage/api/MagicLinksDb.java new file mode 100644 index 00000000000..65101d5eedf --- /dev/null +++ b/src/main/java/teammates/storage/api/MagicLinksDb.java @@ -0,0 +1,68 @@ +package teammates.storage.api; + +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.Root; + +import teammates.common.util.HibernateUtil; +import teammates.storage.entity.MagicLink; + +/** + * Handles CRUD operations for magic links. + * + * @see MagicLink + */ +public final class MagicLinksDb { + private static final MagicLinksDb instance = new MagicLinksDb(); + + private MagicLinksDb() { + // prevent initialization + } + + public static MagicLinksDb inst() { + return instance; + } + + /** + * Atomically creates or updates a MagicLink by email and returns the persisted row. + */ + public MagicLink upsertMagicLink(MagicLink magicLink) { + String sql = """ + INSERT INTO magic_links (id, created_at, email, token_hash, expires_at, updated_at) + VALUES (:id, CURRENT_TIMESTAMP, :email, :tokenHash, :expiresAt, CURRENT_TIMESTAMP) + ON CONFLICT (email) + DO UPDATE SET token_hash = :tokenHash, + expires_at = :expiresAt, + updated_at = CURRENT_TIMESTAMP + RETURNING * + """; + + return HibernateUtil.createNativeQuery(sql, MagicLink.class) + .setParameter("id", magicLink.getId()) + .setParameter("email", magicLink.getEmail()) + .setParameter("tokenHash", magicLink.getTokenHash()) + .setParameter("expiresAt", magicLink.getExpiresAt()) + .getSingleResult(); + } + + /** + * Returns a MagicLink with the given token hash or null if it does not exist. + */ + public MagicLink getMagicLinkByTokenHash(String tokenHash) { + CriteriaBuilder cb = HibernateUtil.getCriteriaBuilder(); + CriteriaQuery cr = cb.createQuery(MagicLink.class); + Root root = cr.from(MagicLink.class); + + cr.select(root).where(cb.equal(root.get("tokenHash"), tokenHash)); + + return HibernateUtil.createQuery(cr).getResultStream().findFirst().orElse(null); + } + + /** + * Deletes a MagicLink. + */ + public void deleteMagicLink(MagicLink magicLink) { + HibernateUtil.remove(magicLink); + } + +} From 3347d5a17036cd18355d97101c4e8f76b0e3730e Mon Sep 17 00:00:00 2001 From: YongJunXi Date: Wed, 15 Jul 2026 11:08:15 +0800 Subject: [PATCH 07/14] Add db tests --- .../storage/api/MagicLinksDbTest.java | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 src/test/java/teammates/storage/api/MagicLinksDbTest.java diff --git a/src/test/java/teammates/storage/api/MagicLinksDbTest.java b/src/test/java/teammates/storage/api/MagicLinksDbTest.java new file mode 100644 index 00000000000..5329548f5a8 --- /dev/null +++ b/src/test/java/teammates/storage/api/MagicLinksDbTest.java @@ -0,0 +1,85 @@ +package teammates.storage.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.time.Instant; +import java.util.UUID; + +import org.testng.annotations.Test; + +import teammates.storage.entity.MagicLink; +import teammates.test.GroupNames; + +/** + * Tests for {@link MagicLinksDb}. + */ +public class MagicLinksDbTest extends BaseDbTestcase { + private final MagicLinksDb magicLinksDb = MagicLinksDb.inst(); + + @Test(groups = GroupNames.DB) + public void upsertMagicLink_magicLinkDoesNotExist_magicLinkIsInserted() { + UUID magicLinkId = given.uuid("magic-link"); + MagicLink magicLink = buildDefaultMagicLink(magicLinkId, "insert@example.com", "insert-token-hash"); + + MagicLink actual = inTransaction(() -> magicLinksDb.upsertMagicLink(magicLink)); + + assertEquals(magicLinkId, actual.getId()); + assertEquals("insert@example.com", actual.getEmail()); + assertEquals("insert-token-hash", actual.getTokenHash()); + verifyPresentInDatabase(MagicLink.class, magicLinkId); + } + + @Test(groups = GroupNames.DB) + public void upsertMagicLink_emailExists_updatesExistingMagicLink() { + MagicLink existingMagicLink = inTransaction(() -> magicLinksDb.upsertMagicLink( + buildDefaultMagicLink(given.uuid("existing-magic-link"), "upsert@example.com", "old-upsert-token-hash"))); + MagicLink updatedMagicLink = buildDefaultMagicLink( + given.uuid("updated-magic-link"), "upsert@example.com", "new-upsert-token-hash"); + + MagicLink actual = inTransaction(() -> magicLinksDb.upsertMagicLink(updatedMagicLink)); + + assertEquals(existingMagicLink.getId(), actual.getId()); + assertEquals("upsert@example.com", actual.getEmail()); + assertEquals("new-upsert-token-hash", actual.getTokenHash()); + assertNull(inTransaction(() -> magicLinksDb.getMagicLinkByTokenHash("old-upsert-token-hash"))); + } + + @Test(groups = GroupNames.DB) + public void getMagicLinkByTokenHash_magicLinkExists_returnsMagicLink() { + MagicLink magicLink = inTransaction(() -> magicLinksDb.upsertMagicLink( + buildDefaultMagicLink(given.uuid("magic-link"), "lookup@example.com", "lookup-token-hash"))); + + MagicLink actual = inTransaction(() -> magicLinksDb.getMagicLinkByTokenHash("lookup-token-hash")); + + assertNotNull(actual); + assertEquals(magicLink.getId(), actual.getId()); + } + + @Test(groups = GroupNames.DB) + public void getMagicLinkByTokenHash_magicLinkDoesNotExist_returnsNull() { + inTransaction(() -> magicLinksDb.upsertMagicLink( + buildDefaultMagicLink(given.uuid("magic-link"), "missing-lookup@example.com", "existing-token-hash"))); + + MagicLink actual = inTransaction(() -> magicLinksDb.getMagicLinkByTokenHash("non-existent-token-hash")); + + assertNull(actual); + } + + @Test(groups = GroupNames.DB) + public void deleteMagicLink_magicLinkExists_magicLinkIsRemoved() { + MagicLink magicLink = inTransaction(() -> magicLinksDb.upsertMagicLink( + buildDefaultMagicLink(given.uuid("magic-link"), "delete@example.com", "delete-token-hash"))); + + inTransaction(() -> magicLinksDb.deleteMagicLink(magicLinksDb.getMagicLinkByTokenHash("delete-token-hash"))); + + verifyAbsentInDatabase(MagicLink.class, magicLink.getId()); + } + + private static MagicLink buildDefaultMagicLink(UUID magicLinkId, String email, String tokenHash) { + MagicLink magicLink = new MagicLink(email, tokenHash, Instant.now()); + magicLink.setId(magicLinkId); + return magicLink; + } +} From 0ad647fb60cf130a3315fa1e144254bfd8690d71 Mon Sep 17 00:00:00 2001 From: YongJunXi Date: Wed, 15 Jul 2026 11:15:01 +0800 Subject: [PATCH 08/14] Use given --- .../common/datatransfer/DataBundle.java | 2 + .../teammates/logic/core/DataBundleLogic.java | 9 ++++ .../storage/api/MagicLinksDbTest.java | 52 +++++++++--------- .../test/BaseTestCaseWithDatabaseAccess.java | 1 + .../test/scenariobuilder/GivenData.java | 30 +++++++++++ .../test/scenariobuilder/GivenMagicLink.java | 54 +++++++++++++++++++ 6 files changed, 121 insertions(+), 27 deletions(-) create mode 100644 src/test/java/teammates/test/scenariobuilder/GivenMagicLink.java diff --git a/src/main/java/teammates/common/datatransfer/DataBundle.java b/src/main/java/teammates/common/datatransfer/DataBundle.java index bb3a081827c..59b3ab524ef 100644 --- a/src/main/java/teammates/common/datatransfer/DataBundle.java +++ b/src/main/java/teammates/common/datatransfer/DataBundle.java @@ -13,6 +13,7 @@ import teammates.storage.entity.FeedbackSessionLog; import teammates.storage.entity.Institute; import teammates.storage.entity.Instructor; +import teammates.storage.entity.MagicLink; import teammates.storage.entity.Notification; import teammates.storage.entity.ReadNotification; import teammates.storage.entity.ResponseInstructorComment; @@ -42,6 +43,7 @@ public class DataBundle { public Map feedbackResponses = new LinkedHashMap<>(); public Map responseInstructorComments = new LinkedHashMap<>(); public Map feedbackSessionLogs = new LinkedHashMap<>(); + public Map magicLinks = new LinkedHashMap<>(); public Map notifications = new LinkedHashMap<>(); public Map readNotifications = new LinkedHashMap<>(); } diff --git a/src/main/java/teammates/logic/core/DataBundleLogic.java b/src/main/java/teammates/logic/core/DataBundleLogic.java index 3317d478a72..86df2dd819f 100644 --- a/src/main/java/teammates/logic/core/DataBundleLogic.java +++ b/src/main/java/teammates/logic/core/DataBundleLogic.java @@ -31,6 +31,7 @@ import teammates.storage.entity.InstructorCoursePrivilege; import teammates.storage.entity.InstructorSectionPrivilege; import teammates.storage.entity.InstructorSessionPrivilege; +import teammates.storage.entity.MagicLink; import teammates.storage.entity.Notification; import teammates.storage.entity.ReadNotification; import teammates.storage.entity.ResponseGiver; @@ -114,6 +115,7 @@ public static DataBundle deserializeDataBundle(String jsonString, String seed) { Collection responses = dataBundle.feedbackResponses.values(); Collection responseComments = dataBundle.responseInstructorComments.values(); Collection deadlineExtensions = dataBundle.deadlineExtensions.values(); + Collection magicLinks = dataBundle.magicLinks.values(); Collection notifications = dataBundle.notifications.values(); Collection readNotifications = dataBundle.readNotifications.values(); @@ -274,6 +276,11 @@ public static DataBundle deserializeDataBundle(String jsonString, String seed) { notificationsMap.put(placeholderId, notification); } + for (MagicLink magicLink : magicLinks) { + UUID placeholderId = magicLink.getId(); + magicLink.setId(generateId(placeholderId, seed)); + } + for (ReadNotification readNotification : readNotifications) { UUID placeholderId = readNotification.getId(); readNotification.setId(generateId(placeholderId, seed)); @@ -418,10 +425,12 @@ public DataBundle persistDataBundle(DataBundle dataBundle) Collection responses = dataBundle.feedbackResponses.values(); Collection responseComments = dataBundle.responseInstructorComments.values(); Collection deadlineExtensions = dataBundle.deadlineExtensions.values(); + Collection magicLinks = dataBundle.magicLinks.values(); Collection notifications = dataBundle.notifications.values(); Collection readNotifications = dataBundle.readNotifications.values(); persistEntities(institutes); + persistEntities(magicLinks); persistEntities(notifications); persistEntities(accounts); persistEntities(accountVerificationRequests); diff --git a/src/test/java/teammates/storage/api/MagicLinksDbTest.java b/src/test/java/teammates/storage/api/MagicLinksDbTest.java index 5329548f5a8..9da3920f99f 100644 --- a/src/test/java/teammates/storage/api/MagicLinksDbTest.java +++ b/src/test/java/teammates/storage/api/MagicLinksDbTest.java @@ -4,9 +4,6 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; -import java.time.Instant; -import java.util.UUID; - import org.testng.annotations.Test; import teammates.storage.entity.MagicLink; @@ -20,27 +17,31 @@ public class MagicLinksDbTest extends BaseDbTestcase { @Test(groups = GroupNames.DB) public void upsertMagicLink_magicLinkDoesNotExist_magicLinkIsInserted() { - UUID magicLinkId = given.uuid("magic-link"); - MagicLink magicLink = buildDefaultMagicLink(magicLinkId, "insert@example.com", "insert-token-hash"); + var magicLinkRef = given.magicLink("magic-link", + ml -> ml.email("insert@example.com").tokenHash("insert-token-hash")); + MagicLink magicLink = given.getDataBundle().magicLinks.get(magicLinkRef.alias()); MagicLink actual = inTransaction(() -> magicLinksDb.upsertMagicLink(magicLink)); - assertEquals(magicLinkId, actual.getId()); + assertEquals(magicLinkRef.id(), actual.getId()); assertEquals("insert@example.com", actual.getEmail()); assertEquals("insert-token-hash", actual.getTokenHash()); - verifyPresentInDatabase(MagicLink.class, magicLinkId); + verifyPresentInDatabase(MagicLink.class, magicLinkRef.id()); } @Test(groups = GroupNames.DB) public void upsertMagicLink_emailExists_updatesExistingMagicLink() { - MagicLink existingMagicLink = inTransaction(() -> magicLinksDb.upsertMagicLink( - buildDefaultMagicLink(given.uuid("existing-magic-link"), "upsert@example.com", "old-upsert-token-hash"))); - MagicLink updatedMagicLink = buildDefaultMagicLink( - given.uuid("updated-magic-link"), "upsert@example.com", "new-upsert-token-hash"); + var existingMagicLink = given.magicLink("existing-magic-link", + ml -> ml.email("upsert@example.com").tokenHash("old-upsert-token-hash")); + var updatedMagicLink = given.magicLink("updated-magic-link", + ml -> ml.email("upsert@example.com").tokenHash("new-upsert-token-hash")); + inTransaction(() -> magicLinksDb.upsertMagicLink( + given.getDataBundle().magicLinks.get(existingMagicLink.alias()))); - MagicLink actual = inTransaction(() -> magicLinksDb.upsertMagicLink(updatedMagicLink)); + MagicLink actual = inTransaction( + () -> magicLinksDb.upsertMagicLink(given.getDataBundle().magicLinks.get(updatedMagicLink.alias()))); - assertEquals(existingMagicLink.getId(), actual.getId()); + assertEquals(existingMagicLink.id(), actual.getId()); assertEquals("upsert@example.com", actual.getEmail()); assertEquals("new-upsert-token-hash", actual.getTokenHash()); assertNull(inTransaction(() -> magicLinksDb.getMagicLinkByTokenHash("old-upsert-token-hash"))); @@ -48,19 +49,21 @@ public void upsertMagicLink_emailExists_updatesExistingMagicLink() { @Test(groups = GroupNames.DB) public void getMagicLinkByTokenHash_magicLinkExists_returnsMagicLink() { - MagicLink magicLink = inTransaction(() -> magicLinksDb.upsertMagicLink( - buildDefaultMagicLink(given.uuid("magic-link"), "lookup@example.com", "lookup-token-hash"))); + var magicLink = given.magicLink("magic-link", + ml -> ml.email("lookup@example.com").tokenHash("lookup-token-hash")); + persistGivenData(given); MagicLink actual = inTransaction(() -> magicLinksDb.getMagicLinkByTokenHash("lookup-token-hash")); assertNotNull(actual); - assertEquals(magicLink.getId(), actual.getId()); + assertEquals(magicLink.id(), actual.getId()); } @Test(groups = GroupNames.DB) public void getMagicLinkByTokenHash_magicLinkDoesNotExist_returnsNull() { - inTransaction(() -> magicLinksDb.upsertMagicLink( - buildDefaultMagicLink(given.uuid("magic-link"), "missing-lookup@example.com", "existing-token-hash"))); + given.magicLink("magic-link", + ml -> ml.email("missing-lookup@example.com").tokenHash("existing-token-hash")); + persistGivenData(given); MagicLink actual = inTransaction(() -> magicLinksDb.getMagicLinkByTokenHash("non-existent-token-hash")); @@ -69,17 +72,12 @@ public void getMagicLinkByTokenHash_magicLinkDoesNotExist_returnsNull() { @Test(groups = GroupNames.DB) public void deleteMagicLink_magicLinkExists_magicLinkIsRemoved() { - MagicLink magicLink = inTransaction(() -> magicLinksDb.upsertMagicLink( - buildDefaultMagicLink(given.uuid("magic-link"), "delete@example.com", "delete-token-hash"))); + var magicLink = given.magicLink("magic-link", + ml -> ml.email("delete@example.com").tokenHash("delete-token-hash")); + persistGivenData(given); inTransaction(() -> magicLinksDb.deleteMagicLink(magicLinksDb.getMagicLinkByTokenHash("delete-token-hash"))); - verifyAbsentInDatabase(MagicLink.class, magicLink.getId()); - } - - private static MagicLink buildDefaultMagicLink(UUID magicLinkId, String email, String tokenHash) { - MagicLink magicLink = new MagicLink(email, tokenHash, Instant.now()); - magicLink.setId(magicLinkId); - return magicLink; + verifyAbsentInDatabase(MagicLink.class, magicLink.id()); } } diff --git a/src/test/java/teammates/test/BaseTestCaseWithDatabaseAccess.java b/src/test/java/teammates/test/BaseTestCaseWithDatabaseAccess.java index 97cd4be8dae..4dc4f94a361 100644 --- a/src/test/java/teammates/test/BaseTestCaseWithDatabaseAccess.java +++ b/src/test/java/teammates/test/BaseTestCaseWithDatabaseAccess.java @@ -178,6 +178,7 @@ private void clearDatabase() { feedback_sessions, institutes, instructors, + magic_links, notifications, read_notifications, response_instructor_comments, diff --git a/src/test/java/teammates/test/scenariobuilder/GivenData.java b/src/test/java/teammates/test/scenariobuilder/GivenData.java index 052b150489d..95b79c37e55 100644 --- a/src/test/java/teammates/test/scenariobuilder/GivenData.java +++ b/src/test/java/teammates/test/scenariobuilder/GivenData.java @@ -18,6 +18,7 @@ import teammates.storage.entity.FeedbackSessionLog; import teammates.storage.entity.Institute; import teammates.storage.entity.Instructor; +import teammates.storage.entity.MagicLink; import teammates.storage.entity.Notification; import teammates.storage.entity.ReadNotification; import teammates.storage.entity.ResponseInstructorComment; @@ -382,6 +383,25 @@ public NotificationRef notification(String alias, Consumer op return new NotificationRef(notification.getId(), alias); } + /** + * Creates a magic link with default values. + */ + public MagicLinkRef magicLink(String alias) { + return magicLink(alias, ml -> { + }); + } + + /** + * Creates a magic link and applies the provided options to customize it. + */ + public MagicLinkRef magicLink(String alias, Consumer options) { + GivenMagicLink magicLinkData = new GivenMagicLink(this, uuid(alias)); + options.accept(magicLinkData); + MagicLink magicLink = magicLinkData.build(); + registerEntity(alias, magicLink, dataBundle.magicLinks); + return new MagicLinkRef(magicLink.getId(), alias, magicLink.getEmail(), magicLink.getTokenHash()); + } + /** * Creates a read notification with default values. */ @@ -570,6 +590,16 @@ public record DeadlineExtensionRef(UUID id, String alias) {} */ public record FeedbackSessionLogRef(UUID id, String alias) {} + /** + * Reference to a magic link created by GivenData. + * + * @param id generated entity ID + * @param alias GivenData alias + * @param email email address + * @param tokenHash token hash + */ + public record MagicLinkRef(UUID id, String alias, String email, String tokenHash) {} + /** * Reference to a notification created by GivenData. * diff --git a/src/test/java/teammates/test/scenariobuilder/GivenMagicLink.java b/src/test/java/teammates/test/scenariobuilder/GivenMagicLink.java new file mode 100644 index 00000000000..59d3b88a230 --- /dev/null +++ b/src/test/java/teammates/test/scenariobuilder/GivenMagicLink.java @@ -0,0 +1,54 @@ +package teammates.test.scenariobuilder; + +import java.time.Instant; +import java.util.UUID; + +import teammates.storage.entity.MagicLink; + +/** + * Builder for MagicLink entities used in test scenarios. + */ +public final class GivenMagicLink extends GivenBase { + public GivenMagicLink(GivenData given, UUID magicLinkId) { + super(given); + this.entity = defaultMagicLink(magicLinkId); + } + + /** + * Sets the email for the magic link. + */ + public GivenMagicLink email(String email) { + entity.setEmail(email); + return this; + } + + /** + * Sets the token hash for the magic link. + */ + public GivenMagicLink tokenHash(String tokenHash) { + entity.setTokenHash(tokenHash); + return this; + } + + /** + * Sets the expiry time for the magic link. + */ + public GivenMagicLink expiresAt(Instant expiresAt) { + entity.setExpiresAt(expiresAt); + return this; + } + + @Override + void ensureConsistent() { + // No mandatory relationships + } + + private MagicLink defaultMagicLink(UUID magicLinkId) { + MagicLink magicLink = new MagicLink( + magicLinkId.toString() + "@teammates.tmt", + "token-hash:" + magicLinkId.toString(), + Instant.now()); + magicLink.setId(magicLinkId); + return magicLink; + } +} From e62200595beb03961a6860fefd4d9ac18f976104 Mon Sep 17 00:00:00 2001 From: YongJunXi Date: Wed, 15 Jul 2026 12:20:13 +0800 Subject: [PATCH 09/14] Add logic layer methods --- src/main/java/teammates/logic/api/Logic.java | 32 +++++ .../teammates/logic/core/LogicStarter.java | 3 + .../teammates/logic/core/MagicLinksLogic.java | 117 ++++++++++++++++++ .../logic/core/MagicLinksLogicTest.java | 111 +++++++++++++++++ 4 files changed, 263 insertions(+) create mode 100644 src/main/java/teammates/logic/core/MagicLinksLogic.java create mode 100644 src/test/java/teammates/logic/core/MagicLinksLogicTest.java diff --git a/src/main/java/teammates/logic/api/Logic.java b/src/main/java/teammates/logic/api/Logic.java index 32b87429c9e..aa17de24b15 100644 --- a/src/main/java/teammates/logic/api/Logic.java +++ b/src/main/java/teammates/logic/api/Logic.java @@ -52,6 +52,7 @@ import teammates.logic.core.FeedbackSessionsLogic; import teammates.logic.core.InstitutesLogic; import teammates.logic.core.InstructorPermissionsLogic; +import teammates.logic.core.MagicLinksLogic; import teammates.logic.core.NotificationsLogic; import teammates.logic.core.ResponseInstructorCommentsLogic; import teammates.logic.core.UsageStatisticsLogic; @@ -66,6 +67,7 @@ import teammates.storage.entity.FeedbackSessionLog; import teammates.storage.entity.Institute; import teammates.storage.entity.Instructor; +import teammates.storage.entity.MagicLink; import teammates.storage.entity.Notification; import teammates.storage.entity.ReadNotification; import teammates.storage.entity.ResponseInstructorComment; @@ -115,6 +117,7 @@ public class Logic { final UsageStatisticsLogic usageStatisticsLogic = UsageStatisticsLogic.inst(); final UsersLogic usersLogic = UsersLogic.inst(); final NotificationsLogic notificationsLogic = NotificationsLogic.inst(); + final MagicLinksLogic magicLinksLogic = MagicLinksLogic.inst(); final DataBundleLogic dataBundleLogic = DataBundleLogic.inst(); final InstructorPermissionsLogic instructorPermissionsLogic = InstructorPermissionsLogic.inst(); @@ -1147,6 +1150,35 @@ public List getNotificationsByTargetUsers( return notificationsLogic.getNotificationsByTargetUsers(targetUsers, isActiveOnly); } + /** + * Creates or replaces a magic link for the given email address. + * + * @return the raw one-time token. + */ + public String createMagicLink(String email) throws InvalidParametersException { + return magicLinksLogic.createMagicLink(email); + } + + /** + * Returns a magic link for the given raw token, or null if no matching link exists. + */ + public MagicLink getMagicLinkByToken(String token) { + return magicLinksLogic.getMagicLinkByToken(token); + } + + /** + * Consumes a usable magic link for the given raw token. + * + *

Successful consumption deletes the magic link to enforce one-time use. + * + * @return the consumed magic link. + * @throws EntityDoesNotExistException if the token is unknown. + * @throws InvalidParametersException if the token is expired. + */ + public MagicLink consumeMagicLink(String token) throws EntityDoesNotExistException, InvalidParametersException { + return magicLinksLogic.consumeMagicLink(token); + } + /** * Unlinks the account associated with the user profile without deleting * either entity, allowing the profile to be linked to a different account. diff --git a/src/main/java/teammates/logic/core/LogicStarter.java b/src/main/java/teammates/logic/core/LogicStarter.java index 64556e78522..fe3c10aa167 100644 --- a/src/main/java/teammates/logic/core/LogicStarter.java +++ b/src/main/java/teammates/logic/core/LogicStarter.java @@ -19,6 +19,7 @@ import teammates.storage.api.FeedbackSessionsDb; import teammates.storage.api.InstitutesDb; import teammates.storage.api.InstructorPermissionsDb; +import teammates.storage.api.MagicLinksDb; import teammates.storage.api.NotificationsDb; import teammates.storage.api.ResponseInstructorCommentsDb; import teammates.storage.api.UsersDb; @@ -49,6 +50,7 @@ public static void initializeDependencies() { ResponseInstructorCommentsLogic frcLogic = ResponseInstructorCommentsLogic.inst(); FeedbackQuestionsLogic fqLogic = FeedbackQuestionsLogic.inst(); NotificationsLogic notificationsLogic = NotificationsLogic.inst(); + MagicLinksLogic magicLinksLogic = MagicLinksLogic.inst(); UsageStatisticsLogic usageStatisticsLogic = UsageStatisticsLogic.inst(); UsersLogic usersLogic = UsersLogic.inst(); InstructorPermissionsLogic instructorPermissionsLogic = InstructorPermissionsLogic.inst(); @@ -84,6 +86,7 @@ public static void initializeDependencies() { fqLogic.initLogicDependencies(FeedbackQuestionsDb.inst(), coursesLogic, frLogic, usersLogic, fsLogic, instructorPermissionsLogic); notificationsLogic.initLogicDependencies(NotificationsDb.inst(), accountsLogic); + magicLinksLogic.initLogicDependencies(MagicLinksDb.inst()); usageStatisticsLogic.initLogicDependencies(frLogic, coursesLogic, usersLogic, accountVerificationsLogic); enrollmentLogic.initLogicDependencies(usersLogic, coursesLogic, fsLogic); usersLogic.initLogicDependencies(UsersDb.inst(), coursesLogic, courseJoinEmailsLogic, diff --git a/src/main/java/teammates/logic/core/MagicLinksLogic.java b/src/main/java/teammates/logic/core/MagicLinksLogic.java new file mode 100644 index 00000000000..a2ff61d124b --- /dev/null +++ b/src/main/java/teammates/logic/core/MagicLinksLogic.java @@ -0,0 +1,117 @@ +package teammates.logic.core; + +import java.security.SecureRandom; +import java.time.Instant; +import java.util.Base64; +import java.util.Objects; + +import teammates.common.exception.EntityDoesNotExistException; +import teammates.common.exception.InvalidParametersException; +import teammates.common.util.StringHelper; +import teammates.storage.api.MagicLinksDb; +import teammates.storage.entity.MagicLink; + +/** + * Handles operations related to magic links. + * + * @see MagicLink + * @see MagicLinksDb + */ +public final class MagicLinksLogic { + + private static final MagicLinksLogic instance = new MagicLinksLogic(); + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + private static final int TOKEN_BYTE_LENGTH = 32; + + private MagicLinksDb magicLinksDb; + + private MagicLinksLogic() { + // prevent initialization + } + + public static MagicLinksLogic inst() { + return instance; + } + + void initLogicDependencies(MagicLinksDb magicLinksDb) { + this.magicLinksDb = magicLinksDb; + } + + /** + * Creates or replaces a magic link for the given email address. + * + * @return the raw one-time token. + * @throws InvalidParametersException if the magic link is not valid. + */ + public String createMagicLink(String email) throws InvalidParametersException { + // TODO: Add a method to send the magic link to the user via email. + Objects.requireNonNull(email); + + String token = generateToken(); + MagicLink magicLink = new MagicLink(email, hashToken(token), Instant.now()); + validateMagicLink(magicLink); + + magicLinksDb.upsertMagicLink(magicLink); + return token; + } + + /** + * Returns a magic link for the given raw token, or null if no matching link exists. + */ + public MagicLink getMagicLinkByToken(String token) { + Objects.requireNonNull(token); + return magicLinksDb.getMagicLinkByTokenHash(hashToken(token)); + } + + /** + * Consumes a usable magic link for the given raw token. + * + *

Successful consumption deletes the magic link to enforce one-time use. + * + * @return the consumed magic link. + * @throws InvalidParametersException if the token is unknown or expired. + */ + public MagicLink consumeMagicLink(String token) throws InvalidParametersException, EntityDoesNotExistException { + Objects.requireNonNull(token); + MagicLink magicLink = getMagicLinkByToken(token); + if (magicLink == null) { + throw new EntityDoesNotExistException("Magic link does not exist for the given token."); + } + + if (!magicLink.isUsable(Instant.now())) { + throw new InvalidParametersException("Invalid or expired magic link."); + } + + magicLinksDb.deleteMagicLink(magicLink); + return magicLink; + } + + /** + * Deletes a magic link. + */ + public void deleteMagicLink(MagicLink magicLink) { + Objects.requireNonNull(magicLink); + magicLinksDb.deleteMagicLink(magicLink); + } + + /** + * Hashes a raw magic-link token for storage or lookup. + */ + static String hashToken(String token) { + Objects.requireNonNull(token); + return StringHelper.generateSha256Hmac("magic-link:" + token); + } + + private static String generateToken() { + byte[] tokenBytes = new byte[TOKEN_BYTE_LENGTH]; + SECURE_RANDOM.nextBytes(tokenBytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(tokenBytes); + } + + private void validateMagicLink(MagicLink magicLink) throws InvalidParametersException { + if (!magicLink.isValid()) { + throw new InvalidParametersException(magicLink.getInvalidityInfo()); + } + } + +} diff --git a/src/test/java/teammates/logic/core/MagicLinksLogicTest.java b/src/test/java/teammates/logic/core/MagicLinksLogicTest.java new file mode 100644 index 00000000000..76f6513dff8 --- /dev/null +++ b/src/test/java/teammates/logic/core/MagicLinksLogicTest.java @@ -0,0 +1,111 @@ +package teammates.logic.core; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Instant; + +import org.mockito.ArgumentCaptor; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import teammates.common.exception.EntityDoesNotExistException; +import teammates.common.exception.InvalidParametersException; +import teammates.storage.api.MagicLinksDb; +import teammates.storage.entity.MagicLink; +import teammates.test.BaseTestCase; + +/** + * SUT: {@link MagicLinksLogic}. + */ +public class MagicLinksLogicTest extends BaseTestCase { + + private MagicLinksLogic magicLinksLogic = MagicLinksLogic.inst(); + + private MagicLinksDb magicLinksDb; + + @BeforeMethod + public void setUpMethod() { + magicLinksDb = mock(MagicLinksDb.class); + magicLinksLogic.initLogicDependencies(magicLinksDb); + } + + @Test + public void createMagicLink_validEmail_generatesTokenAndStoresHash() throws InvalidParametersException { + when(magicLinksDb.upsertMagicLink(any(MagicLink.class))).thenAnswer(inv -> inv.getArgument(0)); + + String token = magicLinksLogic.createMagicLink("user@example.com"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(MagicLink.class); + verify(magicLinksDb, times(1)).upsertMagicLink(captor.capture()); + MagicLink persistedMagicLink = captor.getValue(); + + assertNotNull(token); + assertEquals(43, token.length()); + assertEquals("user@example.com", persistedMagicLink.getEmail()); + assertNotEquals(token, persistedMagicLink.getTokenHash()); + assertEquals(MagicLinksLogic.hashToken(token), persistedMagicLink.getTokenHash()); + } + + @Test + public void createMagicLink_invalidEmail_throwsInvalidParametersException() { + assertThrows(InvalidParametersException.class, () -> magicLinksLogic.createMagicLink("invalid-email")); + + verify(magicLinksDb, never()).upsertMagicLink(any(MagicLink.class)); + } + + @Test + public void getMagicLinkByToken_tokenExists_returnsMagicLink() { + String token = "raw-token"; + MagicLink magicLink = new MagicLink("user@example.com", MagicLinksLogic.hashToken(token), Instant.now()); + when(magicLinksDb.getMagicLinkByTokenHash(MagicLinksLogic.hashToken(token))).thenReturn(magicLink); + + MagicLink actual = magicLinksLogic.getMagicLinkByToken(token); + + assertEquals(magicLink, actual); + } + + @Test + public void consumeMagicLink_magicLinkIsUsable_deletesAndReturnsMagicLink() + throws EntityDoesNotExistException, InvalidParametersException { + String token = "raw-token"; + MagicLink magicLink = new MagicLink("user@example.com", MagicLinksLogic.hashToken(token), Instant.now()); + when(magicLinksDb.getMagicLinkByTokenHash(MagicLinksLogic.hashToken(token))).thenReturn(magicLink); + + MagicLink actual = magicLinksLogic.consumeMagicLink(token); + + assertEquals(magicLink, actual); + verify(magicLinksDb, times(1)).deleteMagicLink(magicLink); + } + + @Test + public void consumeMagicLink_magicLinkIsExpired_throwsInvalidParametersExceptionWithoutDeleting() { + String token = "raw-token"; + MagicLink magicLink = new MagicLink("user@example.com", MagicLinksLogic.hashToken(token), Instant.now()); + magicLink.setExpiresAt(Instant.now().minusSeconds(1)); + when(magicLinksDb.getMagicLinkByTokenHash(MagicLinksLogic.hashToken(token))).thenReturn(magicLink); + + InvalidParametersException ex = assertThrows( + InvalidParametersException.class, () -> magicLinksLogic.consumeMagicLink(token)); + + assertEquals("Invalid or expired magic link.", ex.getMessage()); + verify(magicLinksDb, never()).deleteMagicLink(any(MagicLink.class)); + } + + @Test + public void consumeMagicLink_magicLinkDoesNotExist_throwsEntityDoesNotExistException() { + EntityDoesNotExistException ex = assertThrows( + EntityDoesNotExistException.class, () -> magicLinksLogic.consumeMagicLink("raw-token")); + + assertEquals("Magic link does not exist for the given token.", ex.getMessage()); + verify(magicLinksDb, never()).deleteMagicLink(any(MagicLink.class)); + } +} From 122a7175e69ba417479ef2103485dc49c28c2a46 Mon Sep 17 00:00:00 2001 From: YongJunXi Date: Thu, 16 Jul 2026 10:22:06 +0800 Subject: [PATCH 10/14] Make comments same --- src/main/java/teammates/logic/api/Logic.java | 3 ++- src/main/java/teammates/logic/core/MagicLinksLogic.java | 3 ++- src/test/java/teammates/logic/core/MagicLinksLogicTest.java | 5 ----- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/main/java/teammates/logic/api/Logic.java b/src/main/java/teammates/logic/api/Logic.java index aa17de24b15..fb037c806c5 100644 --- a/src/main/java/teammates/logic/api/Logic.java +++ b/src/main/java/teammates/logic/api/Logic.java @@ -1154,6 +1154,7 @@ public List getNotificationsByTargetUsers( * Creates or replaces a magic link for the given email address. * * @return the raw one-time token. + * @throws InvalidParametersException if the magic link is not valid. */ public String createMagicLink(String email) throws InvalidParametersException { return magicLinksLogic.createMagicLink(email); @@ -1173,7 +1174,7 @@ public MagicLink getMagicLinkByToken(String token) { * * @return the consumed magic link. * @throws EntityDoesNotExistException if the token is unknown. - * @throws InvalidParametersException if the token is expired. + * @throws InvalidParametersException if the token is not usable. */ public MagicLink consumeMagicLink(String token) throws EntityDoesNotExistException, InvalidParametersException { return magicLinksLogic.consumeMagicLink(token); diff --git a/src/main/java/teammates/logic/core/MagicLinksLogic.java b/src/main/java/teammates/logic/core/MagicLinksLogic.java index a2ff61d124b..6c6a1910ff8 100644 --- a/src/main/java/teammates/logic/core/MagicLinksLogic.java +++ b/src/main/java/teammates/logic/core/MagicLinksLogic.java @@ -69,7 +69,8 @@ public MagicLink getMagicLinkByToken(String token) { *

Successful consumption deletes the magic link to enforce one-time use. * * @return the consumed magic link. - * @throws InvalidParametersException if the token is unknown or expired. + * @throws EntityDoesNotExistException if the token is unknown. + * @throws InvalidParametersException if the token is not usable. */ public MagicLink consumeMagicLink(String token) throws InvalidParametersException, EntityDoesNotExistException { Objects.requireNonNull(token); diff --git a/src/test/java/teammates/logic/core/MagicLinksLogicTest.java b/src/test/java/teammates/logic/core/MagicLinksLogicTest.java index 76f6513dff8..886572dc54f 100644 --- a/src/test/java/teammates/logic/core/MagicLinksLogicTest.java +++ b/src/test/java/teammates/logic/core/MagicLinksLogicTest.java @@ -1,8 +1,6 @@ package teammates.logic.core; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; @@ -48,10 +46,7 @@ public void createMagicLink_validEmail_generatesTokenAndStoresHash() throws Inva verify(magicLinksDb, times(1)).upsertMagicLink(captor.capture()); MagicLink persistedMagicLink = captor.getValue(); - assertNotNull(token); - assertEquals(43, token.length()); assertEquals("user@example.com", persistedMagicLink.getEmail()); - assertNotEquals(token, persistedMagicLink.getTokenHash()); assertEquals(MagicLinksLogic.hashToken(token), persistedMagicLink.getTokenHash()); } From e88466f827dfc8019b6ad45630fcb95518c7766b Mon Sep 17 00:00:00 2001 From: YongJunXi Date: Thu, 30 Jul 2026 09:54:49 +0800 Subject: [PATCH 11/14] Update method --- src/main/java/teammates/logic/core/MagicLinksLogic.java | 2 +- src/test/java/teammates/logic/core/MagicLinksLogicTest.java | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/teammates/logic/core/MagicLinksLogic.java b/src/main/java/teammates/logic/core/MagicLinksLogic.java index 6c6a1910ff8..772731d4fbc 100644 --- a/src/main/java/teammates/logic/core/MagicLinksLogic.java +++ b/src/main/java/teammates/logic/core/MagicLinksLogic.java @@ -51,7 +51,7 @@ public String createMagicLink(String email) throws InvalidParametersException { MagicLink magicLink = new MagicLink(email, hashToken(token), Instant.now()); validateMagicLink(magicLink); - magicLinksDb.upsertMagicLink(magicLink); + magicLinksDb.persistMagicLink(magicLink); return token; } diff --git a/src/test/java/teammates/logic/core/MagicLinksLogicTest.java b/src/test/java/teammates/logic/core/MagicLinksLogicTest.java index 886572dc54f..9dcd6e89431 100644 --- a/src/test/java/teammates/logic/core/MagicLinksLogicTest.java +++ b/src/test/java/teammates/logic/core/MagicLinksLogicTest.java @@ -38,12 +38,12 @@ public void setUpMethod() { @Test public void createMagicLink_validEmail_generatesTokenAndStoresHash() throws InvalidParametersException { - when(magicLinksDb.upsertMagicLink(any(MagicLink.class))).thenAnswer(inv -> inv.getArgument(0)); + when(magicLinksDb.persistMagicLink(any(MagicLink.class))).thenAnswer(inv -> inv.getArgument(0)); String token = magicLinksLogic.createMagicLink("user@example.com"); ArgumentCaptor captor = ArgumentCaptor.forClass(MagicLink.class); - verify(magicLinksDb, times(1)).upsertMagicLink(captor.capture()); + verify(magicLinksDb, times(1)).persistMagicLink(captor.capture()); MagicLink persistedMagicLink = captor.getValue(); assertEquals("user@example.com", persistedMagicLink.getEmail()); @@ -54,7 +54,7 @@ public void createMagicLink_validEmail_generatesTokenAndStoresHash() throws Inva public void createMagicLink_invalidEmail_throwsInvalidParametersException() { assertThrows(InvalidParametersException.class, () -> magicLinksLogic.createMagicLink("invalid-email")); - verify(magicLinksDb, never()).upsertMagicLink(any(MagicLink.class)); + verify(magicLinksDb, never()).persistMagicLink(any(MagicLink.class)); } @Test From 4028dfae0ea25b8543e3ffa2e397b61335856246 Mon Sep 17 00:00:00 2001 From: YongJunXi Date: Wed, 15 Jul 2026 16:06:43 +0800 Subject: [PATCH 12/14] Create email template and logic class --- .../java/teammates/common/util/Const.java | 2 + .../java/teammates/common/util/EmailType.java | 1 + .../java/teammates/common/util/Templates.java | 2 + src/main/java/teammates/logic/api/Logic.java | 7 ++++ .../teammates/logic/core/LogicStarter.java | 5 ++- .../teammates/logic/core/MagicLinksLogic.java | 26 ++++++++++++- .../teammates/logic/email/EmailRenderer.java | 10 +++++ .../logic/email/MagicLinkEmailsLogic.java | 39 +++++++++++++++++++ .../email/model/MagicLinkEmailContext.java | 10 +++++ .../userEmailTemplate-magicLinkLogin.html | 18 +++++++++ .../logic/core/MagicLinksLogicTest.java | 39 ++++++++++++++++++- 11 files changed, 155 insertions(+), 4 deletions(-) create mode 100644 src/main/java/teammates/logic/email/MagicLinkEmailsLogic.java create mode 100644 src/main/java/teammates/logic/email/model/MagicLinkEmailContext.java create mode 100644 src/main/resources/userEmailTemplate-magicLinkLogin.html diff --git a/src/main/java/teammates/common/util/Const.java b/src/main/java/teammates/common/util/Const.java index 71e04f00599..a10de8e9974 100644 --- a/src/main/java/teammates/common/util/Const.java +++ b/src/main/java/teammates/common/util/Const.java @@ -169,6 +169,7 @@ public static class ParamsNames { public static final String LOGIN_METHOD = "loginMethod"; public static final String EMAIL = "email"; public static final String AUTH_STATE = "state"; + public static final String TOKEN = "token"; } /** @@ -237,6 +238,7 @@ public static class WebPageURIs { private static final String MAINTAINER_PAGE = URI_PREFIX + "/" + EntityType.MAINTAINER; public static final String FRONT_PAGE = URI_PREFIX + "/front"; public static final String JOIN_PAGE = URI_PREFIX + "/join"; + public static final String EMAIL_LOGIN_CONFIRMATION_PAGE = URI_PREFIX + "/email-login/confirm"; public static final String ADMIN_HOME_PAGE = ADMIN_PAGE + "/home"; public static final String ADMIN_ACCOUNTS_PAGE = ADMIN_PAGE + "/accounts/{accountId}"; diff --git a/src/main/java/teammates/common/util/EmailType.java b/src/main/java/teammates/common/util/EmailType.java index d9e1af193d8..e7f014c814e 100644 --- a/src/main/java/teammates/common/util/EmailType.java +++ b/src/main/java/teammates/common/util/EmailType.java @@ -21,6 +21,7 @@ public enum EmailType { STUDENT_COURSE_LINKS_REGENERATED("TEAMMATES: Your updated links for course [%s][Course ID: %s]"), INSTRUCTOR_COURSE_LINKS_REGENERATED("TEAMMATES: Your updated links for course [%s][Course ID: %s]"), ACCOUNT_VERIFICATION_APPROVED("TEAMMATES: Welcome to TEAMMATES, %s!"), + MAGIC_LINK_LOGIN("TEAMMATES: Sign in to TEAMMATES"), STUDENT_COURSE_JOIN("TEAMMATES: Invitation to join course [%s][Course ID: %s]"), STUDENT_COURSE_REJOIN_AFTER_UNLINK_ACCOUNT("TEAMMATES: Your account has been unlinked from course [%s][Course ID: %s]"), NEW_ACCOUNT_VERIFICATION_REQUEST_ADMIN_ALERT("TEAMMATES (Action Needed): New Account Verification Request Received"), diff --git a/src/main/java/teammates/common/util/Templates.java b/src/main/java/teammates/common/util/Templates.java index bf202024bb8..ea659e0aead 100644 --- a/src/main/java/teammates/common/util/Templates.java +++ b/src/main/java/teammates/common/util/Templates.java @@ -88,6 +88,8 @@ public static class EmailTemplates { FileHelper.readResourceFile("accountVerificationApproved.html"); public static final String ACCOUNT_VERIFICATION_REJECTED = FileHelper.readResourceFile("accountVerificationRejected.html"); + public static final String MAGIC_LINK_LOGIN = + FileHelper.readResourceFile("userEmailTemplate-magicLinkLogin.html"); public static final String FRAGMENT_SESSION_ADDITIONAL_CONTACT_INFORMATION = FileHelper.readResourceFile("userEmailFragment-sessionAdditionalContactInformationFragment.html"); public static final String USER_DEADLINE_EXTENSION = diff --git a/src/main/java/teammates/logic/api/Logic.java b/src/main/java/teammates/logic/api/Logic.java index 2d0a404af27..06471850b10 100644 --- a/src/main/java/teammates/logic/api/Logic.java +++ b/src/main/java/teammates/logic/api/Logic.java @@ -1167,6 +1167,13 @@ public String createMagicLink(String email) throws InvalidParametersException { return magicLinksLogic.createMagicLink(email); } + /** + * Creates a magic link and enqueues the sign-in email for the given email address. + */ + public void requestMagicLinkEmail(String email, String encryptedState) throws InvalidParametersException { + magicLinksLogic.requestMagicLinkEmail(email, encryptedState); + } + /** * Returns a magic link for the given raw token, or null if no matching link exists. */ diff --git a/src/main/java/teammates/logic/core/LogicStarter.java b/src/main/java/teammates/logic/core/LogicStarter.java index fe3c10aa167..9e13e0cd25e 100644 --- a/src/main/java/teammates/logic/core/LogicStarter.java +++ b/src/main/java/teammates/logic/core/LogicStarter.java @@ -9,6 +9,7 @@ import teammates.logic.email.DeadlineExtensionEmailsLogic; import teammates.logic.email.EmailQueueService; import teammates.logic.email.FeedbackSessionEmailsLogic; +import teammates.logic.email.MagicLinkEmailsLogic; import teammates.storage.api.AccountVerificationRequestsDb; import teammates.storage.api.AccountsDb; import teammates.storage.api.CoursesDb; @@ -59,12 +60,14 @@ public static void initializeDependencies() { DeadlineExtensionEmailsLogic deadlineExtensionEmailsLogic = DeadlineExtensionEmailsLogic.inst(); FeedbackSessionEmailsLogic feedbackSessionEmailsLogic = FeedbackSessionEmailsLogic.inst(); AccountVerificationEmailsLogic accountVerificationEmailsLogic = AccountVerificationEmailsLogic.inst(); + MagicLinkEmailsLogic magicLinkEmailsLogic = MagicLinkEmailsLogic.inst(); EmailQueueService emailQueueService = EmailQueueService.inst(); courseJoinEmailsLogic.init(emailQueueService); deadlineExtensionEmailsLogic.init(emailQueueService); feedbackSessionEmailsLogic.init(emailQueueService); accountVerificationEmailsLogic.init(emailQueueService); + magicLinkEmailsLogic.init(emailQueueService); authLogic.initLogicDependencies(usersLogic); institutesLogic.initLogicDependencies(InstitutesDb.inst()); @@ -86,7 +89,7 @@ public static void initializeDependencies() { fqLogic.initLogicDependencies(FeedbackQuestionsDb.inst(), coursesLogic, frLogic, usersLogic, fsLogic, instructorPermissionsLogic); notificationsLogic.initLogicDependencies(NotificationsDb.inst(), accountsLogic); - magicLinksLogic.initLogicDependencies(MagicLinksDb.inst()); + magicLinksLogic.initLogicDependencies(MagicLinksDb.inst(), magicLinkEmailsLogic); usageStatisticsLogic.initLogicDependencies(frLogic, coursesLogic, usersLogic, accountVerificationsLogic); enrollmentLogic.initLogicDependencies(usersLogic, coursesLogic, fsLogic); usersLogic.initLogicDependencies(UsersDb.inst(), coursesLogic, courseJoinEmailsLogic, diff --git a/src/main/java/teammates/logic/core/MagicLinksLogic.java b/src/main/java/teammates/logic/core/MagicLinksLogic.java index 772731d4fbc..829eb705bc3 100644 --- a/src/main/java/teammates/logic/core/MagicLinksLogic.java +++ b/src/main/java/teammates/logic/core/MagicLinksLogic.java @@ -7,7 +7,11 @@ import teammates.common.exception.EntityDoesNotExistException; import teammates.common.exception.InvalidParametersException; +import teammates.common.util.Config; +import teammates.common.util.Const; import teammates.common.util.StringHelper; +import teammates.logic.email.MagicLinkEmailsLogic; +import teammates.logic.email.model.MagicLinkEmailContext; import teammates.storage.api.MagicLinksDb; import teammates.storage.entity.MagicLink; @@ -24,6 +28,7 @@ public final class MagicLinksLogic { private static final int TOKEN_BYTE_LENGTH = 32; private MagicLinksDb magicLinksDb; + private MagicLinkEmailsLogic magicLinkEmailsLogic; private MagicLinksLogic() { // prevent initialization @@ -33,8 +38,9 @@ public static MagicLinksLogic inst() { return instance; } - void initLogicDependencies(MagicLinksDb magicLinksDb) { + void initLogicDependencies(MagicLinksDb magicLinksDb, MagicLinkEmailsLogic magicLinkEmailsLogic) { this.magicLinksDb = magicLinksDb; + this.magicLinkEmailsLogic = magicLinkEmailsLogic; } /** @@ -44,7 +50,6 @@ void initLogicDependencies(MagicLinksDb magicLinksDb) { * @throws InvalidParametersException if the magic link is not valid. */ public String createMagicLink(String email) throws InvalidParametersException { - // TODO: Add a method to send the magic link to the user via email. Objects.requireNonNull(email); String token = generateToken(); @@ -55,6 +60,23 @@ public String createMagicLink(String email) throws InvalidParametersException { return token; } + /** + * Creates a magic link and enqueues the sign-in email for the given email address. + * + * @throws InvalidParametersException if the magic link is not valid. + */ + public void requestMagicLinkEmail(String email, String encryptedState) throws InvalidParametersException { + Objects.requireNonNull(encryptedState); + + String token = createMagicLink(email); + String magicLinkUrl = Config.getFrontEndAppUrl(Const.WebPageURIs.EMAIL_LOGIN_CONFIRMATION_PAGE) + .withParam(Const.ParamsNames.AUTH_STATE, encryptedState) + .withParam(Const.ParamsNames.TOKEN, token) + .toAbsoluteString(); + + magicLinkEmailsLogic.enqueueMagicLinkEmail(new MagicLinkEmailContext(email, magicLinkUrl)); + } + /** * Returns a magic link for the given raw token, or null if no matching link exists. */ diff --git a/src/main/java/teammates/logic/email/EmailRenderer.java b/src/main/java/teammates/logic/email/EmailRenderer.java index 7d9e7093d1a..c9867688e31 100644 --- a/src/main/java/teammates/logic/email/EmailRenderer.java +++ b/src/main/java/teammates/logic/email/EmailRenderer.java @@ -29,6 +29,7 @@ import teammates.logic.email.model.FeedbackSessionResultsPreviewEmailContext; import teammates.logic.email.model.FeedbackSessionSummaryEmailContext; import teammates.logic.email.model.InstructorCourseJoinEmailContext; +import teammates.logic.email.model.MagicLinkEmailContext; import teammates.logic.email.model.RenderedEmail; import teammates.logic.email.model.SessionAccessLink; import teammates.logic.email.model.SessionLinksRecoveryContext; @@ -404,6 +405,15 @@ public static RenderedEmail renderAccountVerificationApprovedEmail( "${supportEmail}", Config.SUPPORT_EMAIL)); } + /** + * Renders the magic-link sign-in email body. + */ + public static RenderedEmail renderMagicLinkEmail(MagicLinkEmailContext context) { + return new RenderedEmail(Templates.populateTemplate( + EmailTemplates.MAGIC_LINK_LOGIN, + "${magicLinkUrl}", context.magicLinkUrl())); + } + /** * Renders the rejection email body for a rejected account verification * request. diff --git a/src/main/java/teammates/logic/email/MagicLinkEmailsLogic.java b/src/main/java/teammates/logic/email/MagicLinkEmailsLogic.java new file mode 100644 index 00000000000..c247c8e56c6 --- /dev/null +++ b/src/main/java/teammates/logic/email/MagicLinkEmailsLogic.java @@ -0,0 +1,39 @@ +package teammates.logic.email; + +import teammates.common.util.EmailType; +import teammates.common.util.EmailWrapper; +import teammates.logic.email.model.MagicLinkEmailContext; +import teammates.logic.email.model.RenderedEmail; + +/** + * Handles email-specific orchestration for magic-link sign-in use cases. + */ +public class MagicLinkEmailsLogic { + + private static final MagicLinkEmailsLogic instance = new MagicLinkEmailsLogic(); + + private EmailQueueService emailQueueService; + + public static MagicLinkEmailsLogic inst() { + return instance; + } + + /** + * Initializes the outbound email queue dependency. + */ + public void init(EmailQueueService emailQueueService) { + this.emailQueueService = emailQueueService; + } + + /** + * Enqueues a magic-link sign-in email. + */ + public void enqueueMagicLinkEmail(MagicLinkEmailContext context) { + RenderedEmail renderedEmail = EmailRenderer.renderMagicLinkEmail(context); + EmailWrapper email = EmailWrapperBuilder.build( + context.recipientEmailAddress(), + EmailType.MAGIC_LINK_LOGIN, + renderedEmail); + emailQueueService.enqueuePriority(email); + } +} diff --git a/src/main/java/teammates/logic/email/model/MagicLinkEmailContext.java b/src/main/java/teammates/logic/email/model/MagicLinkEmailContext.java new file mode 100644 index 00000000000..8798a5d828c --- /dev/null +++ b/src/main/java/teammates/logic/email/model/MagicLinkEmailContext.java @@ -0,0 +1,10 @@ +package teammates.logic.email.model; + +/** + * Email context for a magic-link sign-in email. + * + * @param recipientEmailAddress the recipient email address + * @param magicLinkUrl the confirmation-page URL containing the auth state and one-time token + */ +public record MagicLinkEmailContext(String recipientEmailAddress, String magicLinkUrl) { +} diff --git a/src/main/resources/userEmailTemplate-magicLinkLogin.html b/src/main/resources/userEmailTemplate-magicLinkLogin.html new file mode 100644 index 00000000000..da01cca78a9 --- /dev/null +++ b/src/main/resources/userEmailTemplate-magicLinkLogin.html @@ -0,0 +1,18 @@ +

Hello,

+ +

+ Use the link below to continue signing in to TEAMMATES. +

+ +

+ ${magicLinkUrl} +

+ +

+ This link expires shortly and can be used only once. If you did not request this email, you can ignore it. +

+ +

+ Regards, +
TEAMMATES Team. +

diff --git a/src/test/java/teammates/logic/core/MagicLinksLogicTest.java b/src/test/java/teammates/logic/core/MagicLinksLogicTest.java index 9dcd6e89431..482282b3728 100644 --- a/src/test/java/teammates/logic/core/MagicLinksLogicTest.java +++ b/src/test/java/teammates/logic/core/MagicLinksLogicTest.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -17,6 +18,8 @@ import teammates.common.exception.EntityDoesNotExistException; import teammates.common.exception.InvalidParametersException; +import teammates.logic.email.MagicLinkEmailsLogic; +import teammates.logic.email.model.MagicLinkEmailContext; import teammates.storage.api.MagicLinksDb; import teammates.storage.entity.MagicLink; import teammates.test.BaseTestCase; @@ -29,11 +32,13 @@ public class MagicLinksLogicTest extends BaseTestCase { private MagicLinksLogic magicLinksLogic = MagicLinksLogic.inst(); private MagicLinksDb magicLinksDb; + private MagicLinkEmailsLogic magicLinkEmailsLogic; @BeforeMethod public void setUpMethod() { magicLinksDb = mock(MagicLinksDb.class); - magicLinksLogic.initLogicDependencies(magicLinksDb); + magicLinkEmailsLogic = mock(MagicLinkEmailsLogic.class); + magicLinksLogic.initLogicDependencies(magicLinksDb, magicLinkEmailsLogic); } @Test @@ -50,6 +55,29 @@ public void createMagicLink_validEmail_generatesTokenAndStoresHash() throws Inva assertEquals(MagicLinksLogic.hashToken(token), persistedMagicLink.getTokenHash()); } + @Test + public void requestMagicLinkEmail_validEmail_generatesTokenAndEnqueuesEmail() throws InvalidParametersException { + when(magicLinksDb.upsertMagicLink(any(MagicLink.class))).thenAnswer(inv -> inv.getArgument(0)); + + magicLinksLogic.requestMagicLinkEmail("user@example.com", "encrypted-state"); + + ArgumentCaptor magicLinkCaptor = ArgumentCaptor.forClass(MagicLink.class); + verify(magicLinksDb, times(1)).upsertMagicLink(magicLinkCaptor.capture()); + MagicLink persistedMagicLink = magicLinkCaptor.getValue(); + + ArgumentCaptor emailContextCaptor = + ArgumentCaptor.forClass(MagicLinkEmailContext.class); + verify(magicLinkEmailsLogic, times(1)).enqueueMagicLinkEmail(emailContextCaptor.capture()); + MagicLinkEmailContext emailContext = emailContextCaptor.getValue(); + + assertEquals("user@example.com", emailContext.recipientEmailAddress()); + assertTrue(emailContext.magicLinkUrl().contains("/web/email-login/confirm")); + assertTrue(emailContext.magicLinkUrl().contains("state=encrypted-state")); + assertTrue(emailContext.magicLinkUrl().contains("token=")); + String token = emailContext.magicLinkUrl().substring(emailContext.magicLinkUrl().indexOf("token=") + 6); + assertEquals(persistedMagicLink.getTokenHash(), MagicLinksLogic.hashToken(token)); + } + @Test public void createMagicLink_invalidEmail_throwsInvalidParametersException() { assertThrows(InvalidParametersException.class, () -> magicLinksLogic.createMagicLink("invalid-email")); @@ -57,6 +85,15 @@ public void createMagicLink_invalidEmail_throwsInvalidParametersException() { verify(magicLinksDb, never()).persistMagicLink(any(MagicLink.class)); } + @Test + public void requestMagicLinkEmail_invalidEmail_throwsInvalidParametersExceptionWithoutEnqueueing() { + assertThrows(InvalidParametersException.class, + () -> magicLinksLogic.requestMagicLinkEmail("invalid-email", "encrypted-state")); + + verify(magicLinksDb, never()).upsertMagicLink(any(MagicLink.class)); + verify(magicLinkEmailsLogic, never()).enqueueMagicLinkEmail(any(MagicLinkEmailContext.class)); + } + @Test public void getMagicLinkByToken_tokenExists_returnsMagicLink() { String token = "raw-token"; From 071d209ca31afdb8e470cbcff1005eb3c47b77ad Mon Sep 17 00:00:00 2001 From: YongJunXi Date: Thu, 30 Jul 2026 10:08:39 +0800 Subject: [PATCH 13/14] Update methods --- src/main/java/teammates/logic/core/MagicLinksLogic.java | 2 -- src/test/java/teammates/logic/core/MagicLinksLogicTest.java | 6 +++--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/main/java/teammates/logic/core/MagicLinksLogic.java b/src/main/java/teammates/logic/core/MagicLinksLogic.java index 829eb705bc3..949d9549265 100644 --- a/src/main/java/teammates/logic/core/MagicLinksLogic.java +++ b/src/main/java/teammates/logic/core/MagicLinksLogic.java @@ -62,8 +62,6 @@ public String createMagicLink(String email) throws InvalidParametersException { /** * Creates a magic link and enqueues the sign-in email for the given email address. - * - * @throws InvalidParametersException if the magic link is not valid. */ public void requestMagicLinkEmail(String email, String encryptedState) throws InvalidParametersException { Objects.requireNonNull(encryptedState); diff --git a/src/test/java/teammates/logic/core/MagicLinksLogicTest.java b/src/test/java/teammates/logic/core/MagicLinksLogicTest.java index 482282b3728..70d2dbbe42c 100644 --- a/src/test/java/teammates/logic/core/MagicLinksLogicTest.java +++ b/src/test/java/teammates/logic/core/MagicLinksLogicTest.java @@ -57,12 +57,12 @@ public void createMagicLink_validEmail_generatesTokenAndStoresHash() throws Inva @Test public void requestMagicLinkEmail_validEmail_generatesTokenAndEnqueuesEmail() throws InvalidParametersException { - when(magicLinksDb.upsertMagicLink(any(MagicLink.class))).thenAnswer(inv -> inv.getArgument(0)); + when(magicLinksDb.persistMagicLink(any(MagicLink.class))).thenAnswer(inv -> inv.getArgument(0)); magicLinksLogic.requestMagicLinkEmail("user@example.com", "encrypted-state"); ArgumentCaptor magicLinkCaptor = ArgumentCaptor.forClass(MagicLink.class); - verify(magicLinksDb, times(1)).upsertMagicLink(magicLinkCaptor.capture()); + verify(magicLinksDb, times(1)).persistMagicLink(magicLinkCaptor.capture()); MagicLink persistedMagicLink = magicLinkCaptor.getValue(); ArgumentCaptor emailContextCaptor = @@ -90,7 +90,7 @@ public void requestMagicLinkEmail_invalidEmail_throwsInvalidParametersExceptionW assertThrows(InvalidParametersException.class, () -> magicLinksLogic.requestMagicLinkEmail("invalid-email", "encrypted-state")); - verify(magicLinksDb, never()).upsertMagicLink(any(MagicLink.class)); + verify(magicLinksDb, never()).persistMagicLink(any(MagicLink.class)); verify(magicLinkEmailsLogic, never()).enqueueMagicLinkEmail(any(MagicLinkEmailContext.class)); } From 1cb46f53d2e96e6536d566932e5ddfc3698476ce Mon Sep 17 00:00:00 2001 From: YongJunXi Date: Thu, 30 Jul 2026 10:13:43 +0800 Subject: [PATCH 14/14] Generate types --- src/web/types/api-request.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/web/types/api-request.ts b/src/web/types/api-request.ts index ba620b9abaf..8212d145f1e 100644 --- a/src/web/types/api-request.ts +++ b/src/web/types/api-request.ts @@ -305,6 +305,7 @@ export enum EmailType { STUDENT_COURSE_LINKS_REGENERATED = "STUDENT_COURSE_LINKS_REGENERATED", INSTRUCTOR_COURSE_LINKS_REGENERATED = "INSTRUCTOR_COURSE_LINKS_REGENERATED", ACCOUNT_VERIFICATION_APPROVED = "ACCOUNT_VERIFICATION_APPROVED", + MAGIC_LINK_LOGIN = "MAGIC_LINK_LOGIN", STUDENT_COURSE_JOIN = "STUDENT_COURSE_JOIN", STUDENT_COURSE_REJOIN_AFTER_UNLINK_ACCOUNT = "STUDENT_COURSE_REJOIN_AFTER_UNLINK_ACCOUNT", NEW_ACCOUNT_VERIFICATION_REQUEST_ADMIN_ALERT = "NEW_ACCOUNT_VERIFICATION_REQUEST_ADMIN_ALERT",