From ef2be611ae276594004130bfe9c2ef8b5ec00d20 Mon Sep 17 00:00:00 2001 From: DamianUduevbo <58115973+DamianUduevbo@users.noreply.github.com> Date: Mon, 11 Nov 2024 12:20:20 -0500 Subject: [PATCH 01/16] implemented forgot password feature --- .env.example | 30 ++--- packages/db/prisma/schema.prisma | 9 ++ packages/trpc/src/procedures/auth.ts | 3 + .../trpc/src/procedures/forgot-password.ts | 120 ++++++++++++++++++ 4 files changed, 147 insertions(+), 15 deletions(-) create mode 100644 packages/trpc/src/procedures/forgot-password.ts diff --git a/.env.example b/.env.example index 75c63d9..9187fd1 100644 --- a/.env.example +++ b/.env.example @@ -1,23 +1,23 @@ -# Preferred on Mac: -POSTGRES_USER="user" -POSTGRES_PASSWORD="password" -POSTGRES_DATABASE="good-dog" -POSTGRES_PORT=5432 +# # Preferred on Mac: +# POSTGRES_USER="user" +# POSTGRES_PASSWORD="password" +# POSTGRES_DATABASE="good-dog" +# POSTGRES_PORT=5432 -DATABASE_PRISMA_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:${POSTGRES_PORT}/${POSTGRES_DATABASE}" +# DATABASE_PRISMA_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:${POSTGRES_PORT}/${POSTGRES_DATABASE}" -SENDGRID_API_KEY="" -VERIFICATION_FROM_EMAIL="example@gmail.com" +# SENDGRID_API_KEY="" +# VERIFICATION_FROM_EMAIL="example@gmail.com" # Windows Version: # Strings must be in double quotes on Windows, and template strings cannot be used -# POSTGRES_USER="user" -# POSTGRES_PASSWORD="password" -# POSTGRES_DATABASE="good-dog" -# POSTGRES_PORT=5432 +POSTGRES_USER="user" +POSTGRES_PASSWORD="password" +POSTGRES_DATABASE="good-dog" +POSTGRES_PORT=5432 -# DATABASE_PRISMA_URL="postgresql://user:password@localhost:5432/good-dog" +DATABASE_PRISMA_URL="postgresql://user:password@localhost:5432/good-dog" -# SENDGRID_API_KEY="" -# VERIFICATION_FROM_EMAIL="example@gmail.com" \ No newline at end of file +SENDGRID_API_KEY="" +VERIFICATION_FROM_EMAIL="example@gmail.com" \ No newline at end of file diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index ad17127..f67e52a 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -74,3 +74,12 @@ model EmailVerificationCode { updatedAt DateTime @updatedAt expiresAt DateTime } + +model PasswordResetCode { + email String @id + newHashedPassword String + code String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + expiresAt DateTime +} \ No newline at end of file diff --git a/packages/trpc/src/procedures/auth.ts b/packages/trpc/src/procedures/auth.ts index 44cbd2e..e1285df 100644 --- a/packages/trpc/src/procedures/auth.ts +++ b/packages/trpc/src/procedures/auth.ts @@ -82,6 +82,8 @@ export const signUpProcedure = notAuthenticatedProcedureBuilder setSessionCookie(session.sessionId, session.expiresAt); + // delete the verification code after successful sign up + return { message: `Successfully signed up and logged in as ${input.email}.`, }; @@ -167,3 +169,4 @@ export const deleteAccountProcedure = authenticatedProcedureBuilder.mutation( }; }, ); + diff --git a/packages/trpc/src/procedures/forgot-password.ts b/packages/trpc/src/procedures/forgot-password.ts new file mode 100644 index 0000000..b4add13 --- /dev/null +++ b/packages/trpc/src/procedures/forgot-password.ts @@ -0,0 +1,120 @@ +import { setSessionCookie } from "@good-dog/auth/cookies"; +import { hashPassword } from "@good-dog/auth/password"; +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; +import { notAuthenticatedProcedureBuilder } from "../internal/init"; +import { generateSixDigitCode } from "@good-dog/email/email-service"; +import { sendEmailVerification } from "@good-dog/email/verification-email"; + +// click forgot passwd -> enter email -> receive email with code -> enter code -> enter new passwd + +const getNewEmailVerificationCodeExpirationDate = () => + new Date(Date.now() + 60_000 * 15); + +export const sendForgotPasswordEmailProcedure = notAuthenticatedProcedureBuilder + .input( + z.object({ + email: z.string().email(), + newPassword: z.string(), + }), + ) + .mutation(async ({ ctx, input }) => { + // Check if there is already an email verification code for the given email + const user = await ctx.prisma.user.findUnique({ + where: { + email: input.email, + }, + }); + + if (!user) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `No user found with email ${input.email}`, + }); + } + + // Send email. If sending fails, throw error. + const emailCode = generateSixDigitCode(); + + try { + await sendEmailVerification(input.email, emailCode); + } catch (error) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Email confirmation to ${input.email} failed to send.`, + cause: error, + }); + } + + // Create/update the password reset code in the database + await ctx.prisma.passwordResetCode.upsert({ + where: { + email: input.email, + }, + update: { + code: emailCode, + newHashedPassword: await hashPassword(input.newPassword), + expiresAt: getNewEmailVerificationCodeExpirationDate(), + }, + create: { + code: emailCode, + email: input.email, + newHashedPassword: await hashPassword(input.newPassword), + expiresAt: getNewEmailVerificationCodeExpirationDate(), + }, + }); + + return { + message: `Password reset code sent to ${input.email}`, + }; + }); + +// when the code is successfully verified, +export const confirmedPasswordResetProcedure = notAuthenticatedProcedureBuilder + .input( + z.object({ + email: z.string().email(), + code: z.string(), + }), + ) + .mutation(async ({ ctx, input }) => { + const passwordResetCode = await ctx.prisma.passwordResetCode.findUnique({ + where: { + email: input.email, + }, + }); + + if (!passwordResetCode + || passwordResetCode.code !== input.code + || passwordResetCode.expiresAt < new Date()) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: `Invalid or expired code for email ${input.email}`, + }); + } + + // clear seassions and update their password + await ctx.prisma.user.update({ + where: { + email: input.email, + }, + data: { + hashedPassword: passwordResetCode.newHashedPassword, + sessions: { + deleteMany: {}, + }, + }, + }); + + // Delete the password reset code + await ctx.prisma.passwordResetCode.delete({ + where: { + email: input.email, + }, + }); + + return { + message: "Code verified", + }; + }); + From bad422d05a3a09e9b89aa68b7bf0e2c823147314 Mon Sep 17 00:00:00 2001 From: DamianUduevbo <58115973+DamianUduevbo@users.noreply.github.com> Date: Mon, 11 Nov 2024 12:20:33 -0500 Subject: [PATCH 02/16] implemented forgot password feature --- packages/trpc/src/procedures/forgot-password.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/trpc/src/procedures/forgot-password.ts b/packages/trpc/src/procedures/forgot-password.ts index b4add13..cb51819 100644 --- a/packages/trpc/src/procedures/forgot-password.ts +++ b/packages/trpc/src/procedures/forgot-password.ts @@ -19,7 +19,6 @@ export const sendForgotPasswordEmailProcedure = notAuthenticatedProcedureBuilder }), ) .mutation(async ({ ctx, input }) => { - // Check if there is already an email verification code for the given email const user = await ctx.prisma.user.findUnique({ where: { email: input.email, From f0a6e811420d18baf0bdf2d31e5cacb47d02248b Mon Sep 17 00:00:00 2001 From: DamianUduevbo <58115973+DamianUduevbo@users.noreply.github.com> Date: Mon, 11 Nov 2024 12:22:12 -0500 Subject: [PATCH 03/16] implemented forgot password feature --- packages/trpc/src/procedures/forgot-password.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/trpc/src/procedures/forgot-password.ts b/packages/trpc/src/procedures/forgot-password.ts index cb51819..e735f16 100644 --- a/packages/trpc/src/procedures/forgot-password.ts +++ b/packages/trpc/src/procedures/forgot-password.ts @@ -6,7 +6,7 @@ import { notAuthenticatedProcedureBuilder } from "../internal/init"; import { generateSixDigitCode } from "@good-dog/email/email-service"; import { sendEmailVerification } from "@good-dog/email/verification-email"; -// click forgot passwd -> enter email -> receive email with code -> enter code -> enter new passwd +// click forgot passwd -> enter email -> receive email with code -> enter code & new passwd const getNewEmailVerificationCodeExpirationDate = () => new Date(Date.now() + 60_000 * 15); @@ -68,7 +68,7 @@ export const sendForgotPasswordEmailProcedure = notAuthenticatedProcedureBuilder }; }); -// when the code is successfully verified, +// when the user clicks the link in the email, they are taken to a page where they can enter the code and new password. export const confirmedPasswordResetProcedure = notAuthenticatedProcedureBuilder .input( z.object({ From d9df1ee43d946eccfa7487376cfb9a1287776251 Mon Sep 17 00:00:00 2001 From: DamianUduevbo <58115973+DamianUduevbo@users.noreply.github.com> Date: Wed, 13 Nov 2024 19:56:45 -0500 Subject: [PATCH 04/16] implementing schema changes for the passwordresetcode --- packages/db/prisma/schema.prisma | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index f67e52a..ec3108e 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -31,6 +31,9 @@ model User { groups Group[] sessions Session[] sentInvites GroupInvite[] + + passwordResertId PasswordResetCode? + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } @@ -76,10 +79,12 @@ model EmailVerificationCode { } model PasswordResetCode { - email String @id - newHashedPassword String - code String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - expiresAt DateTime + passwordResetId String @id @default(cuid()) + user User @relation(fields: [passwordResetId], references: [userId], onDelete: Cascade) + email String + newHashedPassword String + code String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + expiresAt DateTime } \ No newline at end of file From e3d9d7ac8d93b74c9f8dd9e0d812027418b99552 Mon Sep 17 00:00:00 2001 From: DamianUduevbo <58115973+DamianUduevbo@users.noreply.github.com> Date: Sun, 17 Nov 2024 16:14:45 -0500 Subject: [PATCH 05/16] idk --- packages/db/prisma/schema.prisma | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index ec3108e..db8ba4a 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -21,7 +21,7 @@ enum Role { ADMIN } -model User { +model User { userId String @id @default(uuid()) @map("id") email String @unique firstName String From d3dded00dc7fcc2ef0c267fb1752e20d490c39c5 Mon Sep 17 00:00:00 2001 From: Jordan Praissman Date: Wed, 20 Nov 2024 19:42:23 -0500 Subject: [PATCH 06/16] finished forgot-password procedures --- .../migration.sql | 11 ++ packages/db/prisma/schema.prisma | 7 +- .../trpc/src/procedures/forgot-password.ts | 113 ++++++++++-------- 3 files changed, 74 insertions(+), 57 deletions(-) create mode 100644 packages/db/prisma/migrations/20241121004111_password_reset_request/migration.sql diff --git a/packages/db/prisma/migrations/20241121004111_password_reset_request/migration.sql b/packages/db/prisma/migrations/20241121004111_password_reset_request/migration.sql new file mode 100644 index 0000000..3ce8efc --- /dev/null +++ b/packages/db/prisma/migrations/20241121004111_password_reset_request/migration.sql @@ -0,0 +1,11 @@ +-- CreateTable +CREATE TABLE "PasswordResetReq" ( + "passwordResetId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expiresAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "PasswordResetReq_pkey" PRIMARY KEY ("passwordResetId") +); + +-- AddForeignKey +ALTER TABLE "PasswordResetReq" ADD CONSTRAINT "PasswordResetReq_passwordResetId_fkey" FOREIGN KEY ("passwordResetId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 05c2170..5cceccb 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -34,9 +34,7 @@ model User { isAscapAffiliated Boolean @default(false) isBmiAffiliated Boolean @default(false) groups Group[] - - passwordResetId PasswordResetReq? - + passwordResetReq PasswordResetReq? sessions Session[] sentInvites GroupInvite[] createdAt DateTime @default(now()) @@ -91,9 +89,6 @@ model EmailVerificationCode { model PasswordResetReq { passwordResetId String @id @default(cuid()) user User @relation(fields: [passwordResetId], references: [userId], onDelete: Cascade) - // newHashedPassword String - // code String createdAt DateTime @default(now()) - // updatedAt DateTime @updatedAt expiresAt DateTime } \ No newline at end of file diff --git a/packages/trpc/src/procedures/forgot-password.ts b/packages/trpc/src/procedures/forgot-password.ts index fe7ff7e..92ab24c 100644 --- a/packages/trpc/src/procedures/forgot-password.ts +++ b/packages/trpc/src/procedures/forgot-password.ts @@ -1,30 +1,31 @@ -import { setSessionCookie } from "@good-dog/auth/cookies"; -import { hashPassword } from "@good-dog/auth/password"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; -import { notAuthenticatedProcedureBuilder } from "../internal/init"; -import { generateSixDigitCode } from "@good-dog/email/email-service"; + +import { hashPassword } from "@good-dog/auth/password"; import { sendPasswordResetEmail } from "@good-dog/email/password-reset-email"; -// click forgot passwd -> enter email -> receive email with code -> enter code & new passwd +import { baseProcedureBuilder } from "../internal/init"; -const getNewEmailVerificationCodeExpirationDate = () => +// click forgot passwd -> enter email -> receive unique link -> enter new passwd at given link + +const getNewPasswordResetExpirationDate = () => new Date(Date.now() + 60_000 * 15); -export const sendForgotPasswordEmailProcedure = notAuthenticatedProcedureBuilder +export const sendForgotPasswordEmailProcedure = baseProcedureBuilder .input( z.object({ email: z.string().email(), - newPassword: z.string(), }), ) .mutation(async ({ ctx, input }) => { + // Find user with the given email const user = await ctx.prisma.user.findUnique({ where: { email: input.email, }, }); + // If user with that email doesn't exist, throw error if (!user) { throw new TRPCError({ code: "NOT_FOUND", @@ -32,97 +33,107 @@ export const sendForgotPasswordEmailProcedure = notAuthenticatedProcedureBuilder }); } - // create reset reques. if it exists then delete then create a new one + // Delete any existing password reset requests for the user + await ctx.prisma.passwordResetReq.deleteMany({ + where: { + user: user, + }, + }); + + // Create new password reset request with updated expired at time const pwdResetReq = await ctx.prisma.passwordResetReq.create({ data: { user: { connect: { userId: user.userId, - } + }, + }, + expiresAt: getNewPasswordResetExpirationDate(), + }, + }); + + // Update the user to include the new password reset request + await ctx.prisma.user.update({ + where: { + email: input.email, + }, + data: { + passwordResetReq: { + connect: { + passwordResetId: pwdResetReq.passwordResetId, + }, }, - expiresAt: getNewEmailVerificationCodeExpirationDate(), }, }); + // Send the password reset email try { - //await sendEmailVerification(input.email, emailCode); - await sendPasswordResetEmail(input.email,); + await sendPasswordResetEmail(input.email, pwdResetReq.passwordResetId); } catch (error) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", - message: `Email confirmation to ${input.email} failed to send.`, + message: `Password reset email to ${input.email} failed to send.`, cause: error, }); } - // Create/update the password reset code in the database - await ctx.prisma.passwordResetCode.upsert({ - where: { - email: input.email, - }, - update: { - code: emailCode, - newHashedPassword: await hashPassword(input.newPassword), - expiresAt: getNewEmailVerificationCodeExpirationDate(), - }, - create: { - code: emailCode, - email: input.email, - newHashedPassword: await hashPassword(input.newPassword), - expiresAt: getNewEmailVerificationCodeExpirationDate(), - }, - }); - return { - message: `Password reset code sent to ${input.email}`, + message: `Password reset email sent to ${input.email}.`, }; }); -// when the user clicks the link in the email, they are taken to a page where they can enter the code and new password. -export const confirmedPasswordResetProcedure = notAuthenticatedProcedureBuilder +export const confirmePasswordResetProcedure = baseProcedureBuilder .input( z.object({ email: z.string().email(), - code: z.string(), + newPassword: z.string(), }), ) .mutation(async ({ ctx, input }) => { - const passwordResetCode = await ctx.prisma.passwordResetCode.findUnique({ + // Find user with the given email + const user = await ctx.prisma.user.findUnique({ where: { email: input.email, }, + include: { + passwordResetReq: true, + }, }); - if (!passwordResetCode - || passwordResetCode.code !== input.code - || passwordResetCode.expiresAt < new Date()) { + // If user with that email doesn't exist or they don't have a password reset request, throw error + if (!user?.passwordResetReq) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `No password reset request for the given email.`, + }); + } + + // If password reset request is expired, throw error + if (user.passwordResetReq.expiresAt < new Date()) { throw new TRPCError({ code: "UNAUTHORIZED", - message: `Invalid or expired code for email ${input.email}`, + message: `Password reset request is expired.`, }); } - // clear seassions and update their password + // Update user's password await ctx.prisma.user.update({ where: { email: input.email, }, data: { - hashedPassword: passwordResetCode.newHashedPassword, - sessions: { - deleteMany: {}, - }, + hashedPassword: await hashPassword(input.newPassword), }, }); - // Delete the password reset code - await ctx.prisma.passwordResetCode.delete({ + // Delete the password reset request + await ctx.prisma.passwordResetReq.deleteMany({ where: { - email: input.email, + user: user, }, }); return { - message: "Code verified", + message: "Password reset.", }; - }); \ No newline at end of file + }); From 2e696935391a42f32307ae742ea89bb60bb01542 Mon Sep 17 00:00:00 2001 From: Jordan Praissman Date: Wed, 20 Nov 2024 19:47:52 -0500 Subject: [PATCH 07/16] Formatting issues --- packages/db/prisma/schema.prisma | 26 +++++++++++----------- packages/email/package.json | 2 +- packages/email/src/password-reset-email.ts | 16 ++++++------- packages/trpc/src/procedures/auth.ts | 1 - 4 files changed, 22 insertions(+), 23 deletions(-) diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 5cceccb..82f33f1 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -23,22 +23,22 @@ enum Role { } model User { - userId String @id @default(uuid()) @map("id") - email String @unique - hashedPassword String @map("password") + userId String @id @default(uuid()) @map("id") + email String @unique + hashedPassword String @map("password") firstName String lastName String stageName String? role Role - isSongWriter Boolean @default(false) - isAscapAffiliated Boolean @default(false) - isBmiAffiliated Boolean @default(false) + isSongWriter Boolean @default(false) + isAscapAffiliated Boolean @default(false) + isBmiAffiliated Boolean @default(false) groups Group[] passwordResetReq PasswordResetReq? sessions Session[] sentInvites GroupInvite[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } model Session { @@ -87,8 +87,8 @@ model EmailVerificationCode { } model PasswordResetReq { - passwordResetId String @id @default(cuid()) - user User @relation(fields: [passwordResetId], references: [userId], onDelete: Cascade) - createdAt DateTime @default(now()) - expiresAt DateTime -} \ No newline at end of file + passwordResetId String @id @default(cuid()) + user User @relation(fields: [passwordResetId], references: [userId], onDelete: Cascade) + createdAt DateTime @default(now()) + expiresAt DateTime +} diff --git a/packages/email/package.json b/packages/email/package.json index 361ad94..565f7ff 100644 --- a/packages/email/package.json +++ b/packages/email/package.json @@ -28,4 +28,4 @@ "@good-dog/env": "workspace:*", "@sendgrid/mail": "^8.1.4" } -} \ No newline at end of file +} diff --git a/packages/email/src/password-reset-email.ts b/packages/email/src/password-reset-email.ts index a28c4e6..6b198dc 100644 --- a/packages/email/src/password-reset-email.ts +++ b/packages/email/src/password-reset-email.ts @@ -2,13 +2,13 @@ import { send, setApiKey } from "@good-dog/email/email-service"; import { env } from "@good-dog/env"; export async function sendPasswordResetEmail(toEmail: string, cuid: string) { - setApiKey(env.SENDGRID_API_KEY ?? ""); + setApiKey(env.SENDGRID_API_KEY ?? ""); - const msg = { - to: toEmail, - subject: "Reset Your Password - Good Dog Licensing", - html: `

Follow the link to reset your password: http://localhost:3000/pwdreset/reset_id?=${cuid}

`, - }; + const msg = { + to: toEmail, + subject: "Reset Your Password - Good Dog Licensing", + html: `

Follow the link to reset your password: http://localhost:3000/pwdreset/reset_id?=${cuid}

`, + }; - return await send(msg, env.VERIFICATION_FROM_EMAIL ?? ""); -} \ No newline at end of file + return await send(msg, env.VERIFICATION_FROM_EMAIL ?? ""); +} diff --git a/packages/trpc/src/procedures/auth.ts b/packages/trpc/src/procedures/auth.ts index a12281f..4ac3be0 100644 --- a/packages/trpc/src/procedures/auth.ts +++ b/packages/trpc/src/procedures/auth.ts @@ -175,4 +175,3 @@ export const deleteAccountProcedure = authenticatedProcedureBuilder.mutation( }; }, ); - From e9ed23b6e80b8c86c5e93cfd056c1dab28d1f084 Mon Sep 17 00:00:00 2001 From: Jordan Praissman Date: Wed, 20 Nov 2024 19:53:44 -0500 Subject: [PATCH 08/16] Removed change in auth.ts --- packages/trpc/src/procedures/auth.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/trpc/src/procedures/auth.ts b/packages/trpc/src/procedures/auth.ts index 4ac3be0..d80724f 100644 --- a/packages/trpc/src/procedures/auth.ts +++ b/packages/trpc/src/procedures/auth.ts @@ -88,8 +88,6 @@ export const signUpProcedure = notAuthenticatedProcedureBuilder setSessionCookie(session.sessionId, session.expiresAt); - // delete the verification code after successful sign up - return { message: `Successfully signed up and logged in as ${input.email}.`, }; From 88ceae469224731551d2745b633935744b0a5517 Mon Sep 17 00:00:00 2001 From: Jordan Praissman Date: Fri, 22 Nov 2024 19:56:35 -0500 Subject: [PATCH 09/16] Updated confirm password reset logic --- .../trpc/src/procedures/forgot-password.ts | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/trpc/src/procedures/forgot-password.ts b/packages/trpc/src/procedures/forgot-password.ts index 92ab24c..442cf98 100644 --- a/packages/trpc/src/procedures/forgot-password.ts +++ b/packages/trpc/src/procedures/forgot-password.ts @@ -85,31 +85,31 @@ export const sendForgotPasswordEmailProcedure = baseProcedureBuilder export const confirmePasswordResetProcedure = baseProcedureBuilder .input( z.object({ - email: z.string().email(), + passwordResetId: z.string(), newPassword: z.string(), }), ) .mutation(async ({ ctx, input }) => { - // Find user with the given email - const user = await ctx.prisma.user.findUnique({ + // Find password reset request for given passwordResetId + const passwordResetReq = await ctx.prisma.passwordResetReq.findUnique({ where: { - email: input.email, + passwordResetId: input.passwordResetId, }, include: { - passwordResetReq: true, + user: true, }, }); - // If user with that email doesn't exist or they don't have a password reset request, throw error - if (!user?.passwordResetReq) { + // If password reset request doesn't exist, throw error + if (!passwordResetReq) { throw new TRPCError({ code: "NOT_FOUND", - message: `No password reset request for the given email.`, + message: `No password reset request found for given id.`, }); } // If password reset request is expired, throw error - if (user.passwordResetReq.expiresAt < new Date()) { + if (passwordResetReq.expiresAt < new Date()) { throw new TRPCError({ code: "UNAUTHORIZED", message: `Password reset request is expired.`, @@ -119,7 +119,7 @@ export const confirmePasswordResetProcedure = baseProcedureBuilder // Update user's password await ctx.prisma.user.update({ where: { - email: input.email, + email: passwordResetReq.user.email, }, data: { hashedPassword: await hashPassword(input.newPassword), @@ -127,9 +127,9 @@ export const confirmePasswordResetProcedure = baseProcedureBuilder }); // Delete the password reset request - await ctx.prisma.passwordResetReq.deleteMany({ + await ctx.prisma.passwordResetReq.delete({ where: { - user: user, + passwordResetId: passwordResetReq.passwordResetId, }, }); From b3a53478b296603a4b90e92bee3be5d842ffab20 Mon Sep 17 00:00:00 2001 From: Jordan Praissman Date: Fri, 22 Nov 2024 20:38:45 -0500 Subject: [PATCH 10/16] Started testing forgot password --- packages/trpc/src/internal/router.ts | 6 + .../trpc/src/procedures/forgot-password.ts | 2 +- tests/api/forgot-password.test.ts | 182 ++++++++++++++++++ 3 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 tests/api/forgot-password.test.ts diff --git a/packages/trpc/src/internal/router.ts b/packages/trpc/src/internal/router.ts index 12c4e8a..02169a1 100644 --- a/packages/trpc/src/internal/router.ts +++ b/packages/trpc/src/internal/router.ts @@ -8,6 +8,10 @@ import { confirmEmailProcedure, sendEmailVerificationProcedure, } from "../procedures/email-verification"; +import { + confirmePasswordResetProcedure, + sendForgotPasswordEmailProcedure, +} from "../procedures/forgot-password"; import { onboardingProcedure } from "../procedures/onboarding"; import { getAuthenticatedUserProcedure, @@ -25,6 +29,8 @@ export const appRouter = createTRPCRouter({ deleteAccount: deleteAccountProcedure, authenticatedUser: getAuthenticatedUserProcedure, user: getUserProcedure, + sendForgotPasswordEmail: sendForgotPasswordEmailProcedure, + confirmtPasswordReset: confirmePasswordResetProcedure, }); export type AppRouter = typeof appRouter; diff --git a/packages/trpc/src/procedures/forgot-password.ts b/packages/trpc/src/procedures/forgot-password.ts index 442cf98..2f67db0 100644 --- a/packages/trpc/src/procedures/forgot-password.ts +++ b/packages/trpc/src/procedures/forgot-password.ts @@ -29,7 +29,7 @@ export const sendForgotPasswordEmailProcedure = baseProcedureBuilder if (!user) { throw new TRPCError({ code: "NOT_FOUND", - message: `No user found with email ${input.email}`, + message: `No user found with given email.`, }); } diff --git a/tests/api/forgot-password.test.ts b/tests/api/forgot-password.test.ts new file mode 100644 index 0000000..88ede6d --- /dev/null +++ b/tests/api/forgot-password.test.ts @@ -0,0 +1,182 @@ +import { afterEach, beforeAll, describe, expect, test } from "bun:test"; + +import { hashPassword } from "@good-dog/auth/password"; +import { prisma } from "@good-dog/db"; +import { $trpcCaller } from "@good-dog/trpc/server"; + +import { MockEmailService } from "../mocks/MockEmailService"; +import { MockNextCookies } from "../mocks/MockNextCookies"; + +describe("forgot-password", () => { + const mockCookies = new MockNextCookies(); + const mockEmails = new MockEmailService(); + + const createAccount = async (email: string) => + prisma.user.upsert({ + create: { + firstName: "Walter", + lastName: "White", + role: "MEDIA_MAKER", + email: email, + hashedPassword: await hashPassword("password123"), + }, + update: { + email: email, + hashedPassword: await hashPassword("password123"), + }, + where: { + email: email, + }, + }); + + const cleanupAccount = async (email: string) => { + try { + await prisma.user.delete({ + where: { + email: email, + }, + }); + } catch (error) { + void error; + } + }; + + const createPasswordResetRequest = async (userEmail: string) => + prisma.passwordResetReq.create({ + data: { + expiresAt: new Date(Date.now() + 60_000 * 100000), + user: { + connect: { + email: userEmail, + }, + }, + }, + }); + + const cleanupPasswordResetRequest = async (userEmail: string) => { + try { + await prisma.passwordResetReq.deleteMany({ + where: { + user: { + email: userEmail, + }, + }, + }); + } catch (error) { + void error; + } + }; + + beforeAll(async () => { + await mockCookies.apply(); + await mockEmails.apply(); + }); + + afterEach(() => { + mockCookies.clear(); + mockEmails.clear(); + }); + + describe("forgot-password/sendForgotPasswordEmail", () => { + test("No user with given email", async () => { + await cleanupPasswordResetRequest("walter@gmail.com"); + await cleanupAccount("walter@gmail.com"); + + expect( + $trpcCaller.sendForgotPasswordEmail({ + email: "walter@gmail.com", + }), + ).rejects.toThrow("No user found with given email."); + + expect(mockEmails.setApiKey).not.toBeCalled(); + expect(mockEmails.send).not.toBeCalled(); + }); + + test("Valid user. No pending password reset request.", async () => { + await cleanupPasswordResetRequest("walter@gmail.com"); + await createAccount("walter@gmail.com"); + + const response = await $trpcCaller.sendForgotPasswordEmail({ + email: "walter@gmail.com", + }); + + expect(mockEmails.setApiKey).toBeCalled(); + expect(mockEmails.send).toBeCalled(); + + expect(response.message).toBe( + "Password reset email sent to walter@gmail.com.", + ); + + const user = await prisma.user.findUnique({ + where: { + email: "walter@gmail.com", + }, + }); + if (!user) { + throw new Error("User should not be null."); + } + + const passwordResetReq = await prisma.passwordResetReq.findMany({ + where: { + user: user, + }, + }); + if (!passwordResetReq[0]?.expiresAt) { + throw new Error("There should be a password reset request."); + } + + expect(passwordResetReq.length).toBe(1); + expect(passwordResetReq[0]?.expiresAt > new Date()).toBe(true); + + await cleanupAccount("walter@gmail.com"); + await cleanupPasswordResetRequest("walter@gmail.com"); + }); + + test("Valid user. Pending password reset request.", async () => { + await cleanupPasswordResetRequest("walter@gmail.com"); + await createAccount("walter@gmail.com"); + await createPasswordResetRequest("walter@gmail.com"); + + const user = await prisma.user.findUnique({ + where: { + email: "walter@gmail.com", + }, + }); + if (!user) { + throw new Error("User should not be null."); + } + let passwordResetReqs = await prisma.passwordResetReq.findMany({ + where: { + user: user, + }, + }); + expect(passwordResetReqs.length).toBe(1); + + const response = await $trpcCaller.sendForgotPasswordEmail({ + email: "walter@gmail.com", + }); + + expect(mockEmails.setApiKey).toBeCalled(); + expect(mockEmails.send).toBeCalled(); + + expect(response.message).toBe( + "Password reset email sent to walter@gmail.com.", + ); + + passwordResetReqs = await prisma.passwordResetReq.findMany({ + where: { + user: user, + }, + }); + if (!passwordResetReqs[0]?.expiresAt) { + throw new Error("There should be a password reset request."); + } + + expect(passwordResetReqs.length).toBe(1); + expect(passwordResetReqs[0]?.expiresAt > new Date()).toBe(true); + + await cleanupAccount("walter@gmail.com"); + await cleanupPasswordResetRequest("walter@gmail.com"); + }); + }); +}); From e3b5653a2f7c6ed36255b1594b5ec0653fbeb57a Mon Sep 17 00:00:00 2001 From: Jordan Praissman Date: Sun, 24 Nov 2024 12:17:44 -0500 Subject: [PATCH 11/16] Finished tests --- tests/api/forgot-password.test.ts | 108 ++++++++++++++++++++++++++++-- 1 file changed, 103 insertions(+), 5 deletions(-) diff --git a/tests/api/forgot-password.test.ts b/tests/api/forgot-password.test.ts index 88ede6d..712e5ee 100644 --- a/tests/api/forgot-password.test.ts +++ b/tests/api/forgot-password.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeAll, describe, expect, test } from "bun:test"; -import { hashPassword } from "@good-dog/auth/password"; +import { comparePassword, hashPassword } from "@good-dog/auth/password"; import { prisma } from "@good-dog/db"; import { $trpcCaller } from "@good-dog/trpc/server"; @@ -41,10 +41,13 @@ describe("forgot-password", () => { } }; - const createPasswordResetRequest = async (userEmail: string) => + const createPasswordResetRequest = async ( + userEmail: string, + expiresAt: Date, + ) => prisma.passwordResetReq.create({ data: { - expiresAt: new Date(Date.now() + 60_000 * 100000), + expiresAt: expiresAt, user: { connect: { email: userEmail, @@ -111,6 +114,9 @@ describe("forgot-password", () => { where: { email: "walter@gmail.com", }, + include: { + passwordResetReq: true, + }, }); if (!user) { throw new Error("User should not be null."); @@ -127,6 +133,9 @@ describe("forgot-password", () => { expect(passwordResetReq.length).toBe(1); expect(passwordResetReq[0]?.expiresAt > new Date()).toBe(true); + expect(user.passwordResetReq?.passwordResetId).toBe( + passwordResetReq[0].passwordResetId, + ); await cleanupAccount("walter@gmail.com"); await cleanupPasswordResetRequest("walter@gmail.com"); @@ -135,7 +144,10 @@ describe("forgot-password", () => { test("Valid user. Pending password reset request.", async () => { await cleanupPasswordResetRequest("walter@gmail.com"); await createAccount("walter@gmail.com"); - await createPasswordResetRequest("walter@gmail.com"); + await createPasswordResetRequest( + "walter@gmail.com", + new Date(Date.now() + 60_000 * 100000), + ); const user = await prisma.user.findUnique({ where: { @@ -163,9 +175,20 @@ describe("forgot-password", () => { "Password reset email sent to walter@gmail.com.", ); + const userUpdated = await prisma.user.findUnique({ + where: { + email: "walter@gmail.com", + }, + include: { + passwordResetReq: true, + }, + }); + if (!userUpdated) { + throw new Error("User should not be null."); + } passwordResetReqs = await prisma.passwordResetReq.findMany({ where: { - user: user, + user: userUpdated, }, }); if (!passwordResetReqs[0]?.expiresAt) { @@ -174,6 +197,81 @@ describe("forgot-password", () => { expect(passwordResetReqs.length).toBe(1); expect(passwordResetReqs[0]?.expiresAt > new Date()).toBe(true); + expect(userUpdated.passwordResetReq?.passwordResetId).toBe( + passwordResetReqs[0].passwordResetId, + ); + + await cleanupAccount("walter@gmail.com"); + await cleanupPasswordResetRequest("walter@gmail.com"); + }); + }); + + describe("forgot-password/confirmPasswordReset", () => { + test("Given cuid doesn't exist", () => { + expect( + $trpcCaller.confirmtPasswordReset({ + passwordResetId: "12345", + newPassword: "password", + }), + ).rejects.toThrow("No password reset request found for given id."); + }); + + test("Password reset request is expired", async () => { + await cleanupPasswordResetRequest("walter@gmail.com"); + await createAccount("walter@gmail.com"); + const passwordResetReq = await createPasswordResetRequest( + "walter@gmail.com", + new Date(Date.now() - 10000), + ); + + expect( + $trpcCaller.confirmtPasswordReset({ + passwordResetId: passwordResetReq.passwordResetId, + newPassword: "password", + }), + ).rejects.toThrow("Password reset request is expired."); + + await cleanupAccount("walter@gmail.com"); + await cleanupPasswordResetRequest("walter@gmail.com"); + }); + + test("Password reset request is valid", async () => { + await cleanupPasswordResetRequest("walter@gmail.com"); + await createAccount("walter@gmail.com"); + const passwordResetReq = await createPasswordResetRequest( + "walter@gmail.com", + new Date(Date.now() + 60_000 * 100000), + ); + + const response = await $trpcCaller.confirmtPasswordReset({ + passwordResetId: passwordResetReq.passwordResetId, + newPassword: "newPassword", + }); + + expect(response.message).toBe("Password reset."); + + const user = await prisma.user.findUnique({ + where: { + email: "walter@gmail.com", + }, + include: { + passwordResetReq: true, + }, + }); + if (!user) { + throw new Error("User should not be null."); + } + expect(await comparePassword("newPassword", user.hashedPassword)).toBe( + true, + ); + expect(user.passwordResetReq).toBe(null); + + const oldPasswordResetReq = await prisma.passwordResetReq.findUnique({ + where: { + passwordResetId: passwordResetReq.passwordResetId, + }, + }); + expect(oldPasswordResetReq).toBe(null); await cleanupAccount("walter@gmail.com"); await cleanupPasswordResetRequest("walter@gmail.com"); From 1f44812b5f52400839ff980fe56acc92d54142cb Mon Sep 17 00:00:00 2001 From: Jordan Praissman Date: Sun, 24 Nov 2024 12:27:18 -0500 Subject: [PATCH 12/16] Revert unneeded change --- .env.example | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/.env.example b/.env.example index 9187fd1..75c63d9 100644 --- a/.env.example +++ b/.env.example @@ -1,23 +1,23 @@ -# # Preferred on Mac: -# POSTGRES_USER="user" -# POSTGRES_PASSWORD="password" -# POSTGRES_DATABASE="good-dog" -# POSTGRES_PORT=5432 +# Preferred on Mac: +POSTGRES_USER="user" +POSTGRES_PASSWORD="password" +POSTGRES_DATABASE="good-dog" +POSTGRES_PORT=5432 -# DATABASE_PRISMA_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:${POSTGRES_PORT}/${POSTGRES_DATABASE}" +DATABASE_PRISMA_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:${POSTGRES_PORT}/${POSTGRES_DATABASE}" -# SENDGRID_API_KEY="" -# VERIFICATION_FROM_EMAIL="example@gmail.com" +SENDGRID_API_KEY="" +VERIFICATION_FROM_EMAIL="example@gmail.com" # Windows Version: # Strings must be in double quotes on Windows, and template strings cannot be used -POSTGRES_USER="user" -POSTGRES_PASSWORD="password" -POSTGRES_DATABASE="good-dog" -POSTGRES_PORT=5432 +# POSTGRES_USER="user" +# POSTGRES_PASSWORD="password" +# POSTGRES_DATABASE="good-dog" +# POSTGRES_PORT=5432 -DATABASE_PRISMA_URL="postgresql://user:password@localhost:5432/good-dog" +# DATABASE_PRISMA_URL="postgresql://user:password@localhost:5432/good-dog" -SENDGRID_API_KEY="" -VERIFICATION_FROM_EMAIL="example@gmail.com" \ No newline at end of file +# SENDGRID_API_KEY="" +# VERIFICATION_FROM_EMAIL="example@gmail.com" \ No newline at end of file From d1fea1a20533e14439fd35cb0ceb11c94ba0a6a1 Mon Sep 17 00:00:00 2001 From: Jordan Praissman Date: Sun, 24 Nov 2024 14:58:03 -0500 Subject: [PATCH 13/16] Correct baseURL --- packages/email/src/password-reset-email.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/email/src/password-reset-email.ts b/packages/email/src/password-reset-email.ts index 6b198dc..1773bab 100644 --- a/packages/email/src/password-reset-email.ts +++ b/packages/email/src/password-reset-email.ts @@ -4,10 +4,15 @@ import { env } from "@good-dog/env"; export async function sendPasswordResetEmail(toEmail: string, cuid: string) { setApiKey(env.SENDGRID_API_KEY ?? ""); + let baseURL = "http://localhost:3000"; + if (env.VERCEL_URL) { + baseURL = `https://${env.VERCEL_URL}`; + } + const msg = { to: toEmail, subject: "Reset Your Password - Good Dog Licensing", - html: `

Follow the link to reset your password: http://localhost:3000/pwdreset/reset_id?=${cuid}

`, + html: `

Follow the link to reset your password: ${baseURL}/pwdreset/reset_id?=${cuid}

`, }; return await send(msg, env.VERIFICATION_FROM_EMAIL ?? ""); From eaf1afedc53500b03b744409790e7101b80ed76b Mon Sep 17 00:00:00 2001 From: Jordan Praissman Date: Sun, 24 Nov 2024 15:08:31 -0500 Subject: [PATCH 14/16] Corrected confirm spelling --- packages/trpc/src/internal/router.ts | 4 ++-- packages/trpc/src/procedures/forgot-password.ts | 2 +- tests/api/forgot-password.test.ts | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/trpc/src/internal/router.ts b/packages/trpc/src/internal/router.ts index 02169a1..7cc8937 100644 --- a/packages/trpc/src/internal/router.ts +++ b/packages/trpc/src/internal/router.ts @@ -9,7 +9,7 @@ import { sendEmailVerificationProcedure, } from "../procedures/email-verification"; import { - confirmePasswordResetProcedure, + confirmPasswordResetProcedure, sendForgotPasswordEmailProcedure, } from "../procedures/forgot-password"; import { onboardingProcedure } from "../procedures/onboarding"; @@ -30,7 +30,7 @@ export const appRouter = createTRPCRouter({ authenticatedUser: getAuthenticatedUserProcedure, user: getUserProcedure, sendForgotPasswordEmail: sendForgotPasswordEmailProcedure, - confirmtPasswordReset: confirmePasswordResetProcedure, + confirmPasswordReset: confirmPasswordResetProcedure, }); export type AppRouter = typeof appRouter; diff --git a/packages/trpc/src/procedures/forgot-password.ts b/packages/trpc/src/procedures/forgot-password.ts index 2f67db0..71b963a 100644 --- a/packages/trpc/src/procedures/forgot-password.ts +++ b/packages/trpc/src/procedures/forgot-password.ts @@ -82,7 +82,7 @@ export const sendForgotPasswordEmailProcedure = baseProcedureBuilder }; }); -export const confirmePasswordResetProcedure = baseProcedureBuilder +export const confirmPasswordResetProcedure = baseProcedureBuilder .input( z.object({ passwordResetId: z.string(), diff --git a/tests/api/forgot-password.test.ts b/tests/api/forgot-password.test.ts index 712e5ee..ef6b5ff 100644 --- a/tests/api/forgot-password.test.ts +++ b/tests/api/forgot-password.test.ts @@ -209,7 +209,7 @@ describe("forgot-password", () => { describe("forgot-password/confirmPasswordReset", () => { test("Given cuid doesn't exist", () => { expect( - $trpcCaller.confirmtPasswordReset({ + $trpcCaller.confirmPasswordReset({ passwordResetId: "12345", newPassword: "password", }), @@ -225,7 +225,7 @@ describe("forgot-password", () => { ); expect( - $trpcCaller.confirmtPasswordReset({ + $trpcCaller.confirmPasswordReset({ passwordResetId: passwordResetReq.passwordResetId, newPassword: "password", }), @@ -243,7 +243,7 @@ describe("forgot-password", () => { new Date(Date.now() + 60_000 * 100000), ); - const response = await $trpcCaller.confirmtPasswordReset({ + const response = await $trpcCaller.confirmPasswordReset({ passwordResetId: passwordResetReq.passwordResetId, newPassword: "newPassword", }); From 84b56254d40fc238ed83734c11abdd7253828b74 Mon Sep 17 00:00:00 2001 From: Jordan Praissman Date: Sun, 24 Nov 2024 15:59:38 -0500 Subject: [PATCH 15/16] Fixed change requests --- packages/email/src/password-reset-email.ts | 2 +- .../trpc/src/procedures/forgot-password.ts | 103 ++++++++---------- tests/api/forgot-password.test.ts | 73 ++++++------- 3 files changed, 82 insertions(+), 96 deletions(-) diff --git a/packages/email/src/password-reset-email.ts b/packages/email/src/password-reset-email.ts index 1773bab..1cc7f93 100644 --- a/packages/email/src/password-reset-email.ts +++ b/packages/email/src/password-reset-email.ts @@ -12,7 +12,7 @@ export async function sendPasswordResetEmail(toEmail: string, cuid: string) { const msg = { to: toEmail, subject: "Reset Your Password - Good Dog Licensing", - html: `

Follow the link to reset your password: ${baseURL}/pwdreset/reset_id?=${cuid}

`, + html: `

Follow this link to reset your password.`, }; return await send(msg, env.VERIFICATION_FROM_EMAIL ?? ""); diff --git a/packages/trpc/src/procedures/forgot-password.ts b/packages/trpc/src/procedures/forgot-password.ts index 71b963a..e1eb875 100644 --- a/packages/trpc/src/procedures/forgot-password.ts +++ b/packages/trpc/src/procedures/forgot-password.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { hashPassword } from "@good-dog/auth/password"; import { sendPasswordResetEmail } from "@good-dog/email/password-reset-email"; +import { env } from "@good-dog/env"; import { baseProcedureBuilder } from "../internal/init"; @@ -25,60 +26,50 @@ export const sendForgotPasswordEmailProcedure = baseProcedureBuilder }, }); - // If user with that email doesn't exist, throw error + // If user with that email doesn't exist, return. if (!user) { - throw new TRPCError({ - code: "NOT_FOUND", - message: `No user found with given email.`, - }); + return { + message: `If a user exists for ${input.email}, a password reset link was sent to the email.`, + }; } - // Delete any existing password reset requests for the user - await ctx.prisma.passwordResetReq.deleteMany({ - where: { - user: user, - }, - }); - - // Create new password reset request with updated expired at time - const pwdResetReq = await ctx.prisma.passwordResetReq.create({ - data: { - user: { - connect: { - userId: user.userId, - }, + const [_, pwdResetReq] = await ctx.prisma.$transaction([ + // Delete any existing password reset requests for the user + ctx.prisma.passwordResetReq.deleteMany({ + where: { + user: user, }, - expiresAt: getNewPasswordResetExpirationDate(), - }, - }); - - // Update the user to include the new password reset request - await ctx.prisma.user.update({ - where: { - email: input.email, - }, - data: { - passwordResetReq: { - connect: { - passwordResetId: pwdResetReq.passwordResetId, + }), + // Create new password reset request with updated expired at time + ctx.prisma.passwordResetReq.create({ + data: { + user: { + connect: { + userId: user.userId, + }, }, + expiresAt: getNewPasswordResetExpirationDate(), }, - }, - }); + }), + ]); // Send the password reset email try { await sendPasswordResetEmail(input.email, pwdResetReq.passwordResetId); } catch (error) { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `Password reset email to ${input.email} failed to send.`, - cause: error, - }); + if (env.NODE_ENV === "development") { + console.error(error); + } else { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Password reset email to ${input.email} failed to send.`, + cause: error, + }); + } } return { - message: `Password reset email sent to ${input.email}.`, + message: `If a user exists for ${input.email}, a password reset link was sent to the email.`, }; }); @@ -116,22 +107,24 @@ export const confirmPasswordResetProcedure = baseProcedureBuilder }); } - // Update user's password - await ctx.prisma.user.update({ - where: { - email: passwordResetReq.user.email, - }, - data: { - hashedPassword: await hashPassword(input.newPassword), - }, - }); + await ctx.prisma.$transaction([ + // Update user's password + ctx.prisma.user.update({ + where: { + email: passwordResetReq.user.email, + }, + data: { + hashedPassword: await hashPassword(input.newPassword), + }, + }), - // Delete the password reset request - await ctx.prisma.passwordResetReq.delete({ - where: { - passwordResetId: passwordResetReq.passwordResetId, - }, - }); + // Delete the password reset request + ctx.prisma.passwordResetReq.delete({ + where: { + passwordResetId: passwordResetReq.passwordResetId, + }, + }), + ]); return { message: "Password reset.", diff --git a/tests/api/forgot-password.test.ts b/tests/api/forgot-password.test.ts index ef6b5ff..e5e7747 100644 --- a/tests/api/forgot-password.test.ts +++ b/tests/api/forgot-password.test.ts @@ -85,11 +85,13 @@ describe("forgot-password", () => { await cleanupPasswordResetRequest("walter@gmail.com"); await cleanupAccount("walter@gmail.com"); - expect( - $trpcCaller.sendForgotPasswordEmail({ - email: "walter@gmail.com", - }), - ).rejects.toThrow("No user found with given email."); + const response = await $trpcCaller.sendForgotPasswordEmail({ + email: "walter@gmail.com", + }); + + expect(response.message).toBe( + "If a user exists for walter@gmail.com, a password reset link was sent to the email.", + ); expect(mockEmails.setApiKey).not.toBeCalled(); expect(mockEmails.send).not.toBeCalled(); @@ -107,7 +109,7 @@ describe("forgot-password", () => { expect(mockEmails.send).toBeCalled(); expect(response.message).toBe( - "Password reset email sent to walter@gmail.com.", + "If a user exists for walter@gmail.com, a password reset link was sent to the email.", ); const user = await prisma.user.findUnique({ @@ -118,23 +120,19 @@ describe("forgot-password", () => { passwordResetReq: true, }, }); - if (!user) { - throw new Error("User should not be null."); - } - const passwordResetReq = await prisma.passwordResetReq.findMany({ where: { - user: user, + user: user ?? {}, }, }); - if (!passwordResetReq[0]?.expiresAt) { - throw new Error("There should be a password reset request."); - } expect(passwordResetReq.length).toBe(1); - expect(passwordResetReq[0]?.expiresAt > new Date()).toBe(true); - expect(user.passwordResetReq?.passwordResetId).toBe( - passwordResetReq[0].passwordResetId, + expect( + (passwordResetReq[0]?.expiresAt ?? new Date(Date.now() - 10000000)) > + new Date(), + ).toBe(true); + expect(user?.passwordResetReq?.passwordResetId).toBe( + passwordResetReq[0]?.passwordResetId ?? "", ); await cleanupAccount("walter@gmail.com"); @@ -154,12 +152,9 @@ describe("forgot-password", () => { email: "walter@gmail.com", }, }); - if (!user) { - throw new Error("User should not be null."); - } let passwordResetReqs = await prisma.passwordResetReq.findMany({ where: { - user: user, + user: user ?? {}, }, }); expect(passwordResetReqs.length).toBe(1); @@ -172,7 +167,7 @@ describe("forgot-password", () => { expect(mockEmails.send).toBeCalled(); expect(response.message).toBe( - "Password reset email sent to walter@gmail.com.", + "If a user exists for walter@gmail.com, a password reset link was sent to the email.", ); const userUpdated = await prisma.user.findUnique({ @@ -183,22 +178,19 @@ describe("forgot-password", () => { passwordResetReq: true, }, }); - if (!userUpdated) { - throw new Error("User should not be null."); - } passwordResetReqs = await prisma.passwordResetReq.findMany({ where: { - user: userUpdated, + user: userUpdated ?? {}, }, }); - if (!passwordResetReqs[0]?.expiresAt) { - throw new Error("There should be a password reset request."); - } expect(passwordResetReqs.length).toBe(1); - expect(passwordResetReqs[0]?.expiresAt > new Date()).toBe(true); - expect(userUpdated.passwordResetReq?.passwordResetId).toBe( - passwordResetReqs[0].passwordResetId, + expect( + (passwordResetReqs[0]?.expiresAt ?? new Date(Date.now() - 100000)) > + new Date(), + ).toBe(true); + expect(userUpdated?.passwordResetReq?.passwordResetId).toBe( + passwordResetReqs[0]?.passwordResetId ?? "", ); await cleanupAccount("walter@gmail.com"); @@ -258,13 +250,12 @@ describe("forgot-password", () => { passwordResetReq: true, }, }); - if (!user) { - throw new Error("User should not be null."); - } - expect(await comparePassword("newPassword", user.hashedPassword)).toBe( - true, + const passwordUpdated = await comparePassword( + "newPassword", + user?.hashedPassword ?? "", ); - expect(user.passwordResetReq).toBe(null); + expect(passwordUpdated).toBe(true); + expect(user?.passwordResetReq).toBe(null); const oldPasswordResetReq = await prisma.passwordResetReq.findUnique({ where: { @@ -273,8 +264,10 @@ describe("forgot-password", () => { }); expect(oldPasswordResetReq).toBe(null); - await cleanupAccount("walter@gmail.com"); - await cleanupPasswordResetRequest("walter@gmail.com"); + await Promise.all([ + cleanupAccount("walter@gmail.com"), + cleanupPasswordResetRequest("walter@gmail.com"), + ]); }); }); }); From 8c18491a11c964816637356247abb733dc31f26a Mon Sep 17 00:00:00 2001 From: Jordan Praissman Date: Sun, 24 Nov 2024 16:23:31 -0500 Subject: [PATCH 16/16] Tests are more efficent --- tests/api/forgot-password.test.ts | 49 +++++++++++++------------------ 1 file changed, 21 insertions(+), 28 deletions(-) diff --git a/tests/api/forgot-password.test.ts b/tests/api/forgot-password.test.ts index e5e7747..c9e378f 100644 --- a/tests/api/forgot-password.test.ts +++ b/tests/api/forgot-password.test.ts @@ -1,4 +1,11 @@ -import { afterEach, beforeAll, describe, expect, test } from "bun:test"; +import { + afterEach, + beforeAll, + beforeEach, + describe, + expect, + test, +} from "bun:test"; import { comparePassword, hashPassword } from "@good-dog/auth/password"; import { prisma } from "@good-dog/db"; @@ -71,18 +78,27 @@ describe("forgot-password", () => { }; beforeAll(async () => { - await mockCookies.apply(); - await mockEmails.apply(); + await Promise.all([mockCookies.apply(), mockEmails.apply()]); }); - afterEach(() => { + beforeEach(async () => { + await Promise.all([ + cleanupPasswordResetRequest("walter@gmail.com"), + createAccount("walter@gmail.com"), + ]); + }); + + afterEach(async () => { mockCookies.clear(); mockEmails.clear(); + await Promise.all([ + cleanupPasswordResetRequest("walter@gmail.com"), + cleanupAccount("walter@gmail.com"), + ]); }); describe("forgot-password/sendForgotPasswordEmail", () => { test("No user with given email", async () => { - await cleanupPasswordResetRequest("walter@gmail.com"); await cleanupAccount("walter@gmail.com"); const response = await $trpcCaller.sendForgotPasswordEmail({ @@ -98,9 +114,6 @@ describe("forgot-password", () => { }); test("Valid user. No pending password reset request.", async () => { - await cleanupPasswordResetRequest("walter@gmail.com"); - await createAccount("walter@gmail.com"); - const response = await $trpcCaller.sendForgotPasswordEmail({ email: "walter@gmail.com", }); @@ -134,14 +147,9 @@ describe("forgot-password", () => { expect(user?.passwordResetReq?.passwordResetId).toBe( passwordResetReq[0]?.passwordResetId ?? "", ); - - await cleanupAccount("walter@gmail.com"); - await cleanupPasswordResetRequest("walter@gmail.com"); }); test("Valid user. Pending password reset request.", async () => { - await cleanupPasswordResetRequest("walter@gmail.com"); - await createAccount("walter@gmail.com"); await createPasswordResetRequest( "walter@gmail.com", new Date(Date.now() + 60_000 * 100000), @@ -192,9 +200,6 @@ describe("forgot-password", () => { expect(userUpdated?.passwordResetReq?.passwordResetId).toBe( passwordResetReqs[0]?.passwordResetId ?? "", ); - - await cleanupAccount("walter@gmail.com"); - await cleanupPasswordResetRequest("walter@gmail.com"); }); }); @@ -209,8 +214,6 @@ describe("forgot-password", () => { }); test("Password reset request is expired", async () => { - await cleanupPasswordResetRequest("walter@gmail.com"); - await createAccount("walter@gmail.com"); const passwordResetReq = await createPasswordResetRequest( "walter@gmail.com", new Date(Date.now() - 10000), @@ -222,14 +225,9 @@ describe("forgot-password", () => { newPassword: "password", }), ).rejects.toThrow("Password reset request is expired."); - - await cleanupAccount("walter@gmail.com"); - await cleanupPasswordResetRequest("walter@gmail.com"); }); test("Password reset request is valid", async () => { - await cleanupPasswordResetRequest("walter@gmail.com"); - await createAccount("walter@gmail.com"); const passwordResetReq = await createPasswordResetRequest( "walter@gmail.com", new Date(Date.now() + 60_000 * 100000), @@ -263,11 +261,6 @@ describe("forgot-password", () => { }, }); expect(oldPasswordResetReq).toBe(null); - - await Promise.all([ - cleanupAccount("walter@gmail.com"), - cleanupPasswordResetRequest("walter@gmail.com"), - ]); }); }); });