From 16eef3588238c46bb69ab36a7b9fd922ef9f47af Mon Sep 17 00:00:00 2001 From: YongJunXi Date: Tue, 14 Jul 2026 15:55:05 +0800 Subject: [PATCH 01/10] 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/10] 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/10] 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/10] 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/10] 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 3f6dfe4f5124757ac9d16f82026d12fea7f89771 Mon Sep 17 00:00:00 2001 From: YongJunXi Date: Thu, 16 Jul 2026 09:56:48 +0800 Subject: [PATCH 06/10] Remove naturalId --- src/main/java/teammates/storage/entity/MagicLink.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/main/java/teammates/storage/entity/MagicLink.java b/src/main/java/teammates/storage/entity/MagicLink.java index ae77ded7ee9..61bf3265d68 100644 --- a/src/main/java/teammates/storage/entity/MagicLink.java +++ b/src/main/java/teammates/storage/entity/MagicLink.java @@ -10,7 +10,6 @@ import jakarta.persistence.Id; import jakarta.persistence.Table; -import org.hibernate.annotations.NaturalId; import org.hibernate.annotations.UpdateTimestamp; import teammates.common.util.Const; @@ -27,7 +26,6 @@ public class MagicLink extends BaseEntity { @Id private UUID id; - @NaturalId @Column(nullable = false, unique = true) private String email; From 464b37e0300a66ab99ff6f0ebf955ec175c9c60d Mon Sep 17 00:00:00 2001 From: YongJunXi Date: Wed, 15 Jul 2026 10:50:20 +0800 Subject: [PATCH 07/10] 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 66da9e48f79ad96cd052ce938688f49be2971268 Mon Sep 17 00:00:00 2001 From: YongJunXi Date: Wed, 15 Jul 2026 11:08:15 +0800 Subject: [PATCH 08/10] 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 b7fe96888e1432da726110422c5651d5b01524ea Mon Sep 17 00:00:00 2001 From: YongJunXi Date: Wed, 15 Jul 2026 11:15:01 +0800 Subject: [PATCH 09/10] 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 c04bfe7cf19fa5c416ff830ada48a6ae8f5c5e5a Mon Sep 17 00:00:00 2001 From: YongJunXi Date: Mon, 27 Jul 2026 13:59:32 +0800 Subject: [PATCH 10/10] Persist magic link entity --- .../teammates/storage/api/MagicLinksDb.java | 22 ++++--------------- .../storage/api/MagicLinksDbTest.java | 22 ++----------------- 2 files changed, 6 insertions(+), 38 deletions(-) diff --git a/src/main/java/teammates/storage/api/MagicLinksDb.java b/src/main/java/teammates/storage/api/MagicLinksDb.java index 65101d5eedf..35674beab27 100644 --- a/src/main/java/teammates/storage/api/MagicLinksDb.java +++ b/src/main/java/teammates/storage/api/MagicLinksDb.java @@ -24,25 +24,11 @@ public static MagicLinksDb inst() { } /** - * Atomically creates or updates a MagicLink by email and returns the persisted row. + * Persists a MagicLink. */ - 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(); + public MagicLink persistMagicLink(MagicLink magicLink) { + HibernateUtil.persist(magicLink); + return magicLink; } /** diff --git a/src/test/java/teammates/storage/api/MagicLinksDbTest.java b/src/test/java/teammates/storage/api/MagicLinksDbTest.java index 9da3920f99f..f1b1eb7a1e6 100644 --- a/src/test/java/teammates/storage/api/MagicLinksDbTest.java +++ b/src/test/java/teammates/storage/api/MagicLinksDbTest.java @@ -16,12 +16,12 @@ public class MagicLinksDbTest extends BaseDbTestcase { private final MagicLinksDb magicLinksDb = MagicLinksDb.inst(); @Test(groups = GroupNames.DB) - public void upsertMagicLink_magicLinkDoesNotExist_magicLinkIsInserted() { + public void persistMagicLink_magicLinkDoesNotExist_magicLinkIsInserted() { 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)); + MagicLink actual = inTransaction(() -> magicLinksDb.persistMagicLink(magicLink)); assertEquals(magicLinkRef.id(), actual.getId()); assertEquals("insert@example.com", actual.getEmail()); @@ -29,24 +29,6 @@ public void upsertMagicLink_magicLinkDoesNotExist_magicLinkIsInserted() { verifyPresentInDatabase(MagicLink.class, magicLinkRef.id()); } - @Test(groups = GroupNames.DB) - public void upsertMagicLink_emailExists_updatesExistingMagicLink() { - 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(given.getDataBundle().magicLinks.get(updatedMagicLink.alias()))); - - 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"))); - } - @Test(groups = GroupNames.DB) public void getMagicLinkByTokenHash_magicLinkExists_returnsMagicLink() { var magicLink = given.magicLink("magic-link",