From 9daeafb4bac7ff34d7c3310e4b3398e3901fd62f Mon Sep 17 00:00:00 2001 From: Gautam Raj Date: Sun, 28 Jun 2026 16:50:26 +0530 Subject: [PATCH] feat: add email templates for login alerts, OTP, and welcome messages - Implemented `renderLoginAlertEmail` for notifying users of new logins. - Created `renderOtpEmail` for sending one-time passwords for various actions. - Developed `renderWelcomeEmail` to welcome new users and guide them to start using the platform. refactor: enhance ATS AI service with nullable fields - Updated schemas in `atsAiService` to handle nullable strings and arrays, improving resilience against missing data. fix: adjust ATS quota service to handle TTL correctly - Fixed TTL calculation in `atsQuotaService` to ensure accurate reset times for user quotas. test: add comprehensive tests for authentication and email services - Created tests for rate limiting middleware, mailer functions, and better auth configuration. - Ensured resilience in parsing LLM conversion outputs with null or missing fields. chore: update mock template library for improved project rendering - Enhanced `AtelierTemplate` and `SignalTemplate` to filter and display visible sections and items correctly. --- apps/server/src/auth/index.ts | 32 +++- apps/server/src/auth/mailer.ts | 174 ++++++++---------- apps/server/src/mail/index.ts | 3 + apps/server/src/mail/layout.ts | 111 +++++++++++ apps/server/src/mail/loginAlert.ts | 61 ++++++ apps/server/src/mail/otp.ts | 50 +++++ apps/server/src/mail/welcome.ts | 107 +++++++++++ apps/server/src/services/atsAiService.ts | 123 ++++++++----- apps/server/src/services/atsQuotaService.ts | 7 +- apps/server/tests/ats/ai-service.test.ts | 58 ++++++ .../tests/auth/better-auth-config.test.ts | 138 ++++++++++++++ apps/server/tests/auth/mailer.test.ts | 146 +++++++++++++++ .../tests/auth/rate-limit-middleware.test.ts | 117 ++++++++++++ scripts/mock-template-library.mjs | 104 ++++++++++- 14 files changed, 1084 insertions(+), 147 deletions(-) create mode 100644 apps/server/src/mail/index.ts create mode 100644 apps/server/src/mail/layout.ts create mode 100644 apps/server/src/mail/loginAlert.ts create mode 100644 apps/server/src/mail/otp.ts create mode 100644 apps/server/src/mail/welcome.ts create mode 100644 apps/server/tests/auth/better-auth-config.test.ts create mode 100644 apps/server/tests/auth/mailer.test.ts create mode 100644 apps/server/tests/auth/rate-limit-middleware.test.ts diff --git a/apps/server/src/auth/index.ts b/apps/server/src/auth/index.ts index f34db622..302ce77e 100644 --- a/apps/server/src/auth/index.ts +++ b/apps/server/src/auth/index.ts @@ -9,7 +9,7 @@ import { config } from "#config"; import { prisma } from "#utils/prisma"; import { getRedis } from "#utils/redis"; -import { sendAuthOtpEmail } from "#auth/mailer"; +import { sendAuthOtpEmail, sendWelcomeEmail, sendLoginAlertEmail } from "#auth/mailer"; import { createAuthMiddleware } from "better-auth/api"; import { invalidateSessionCache, invalidateCacheByToken } from "#utils/authCache"; @@ -137,6 +137,16 @@ export const auth = betterAuth({ databaseHooks: { user: { + create: { + after: async (user) => { + try { + await sendWelcomeEmail(user.email, user.name || "there"); + } catch (error) { + console.error("Failed to send welcome email for user:", user.email, error); + } + }, + }, + delete: { before: async (user) => { try { @@ -154,6 +164,26 @@ export const auth = betterAuth({ }, session: { + create: { + after: async (session) => { + try { + const user = await prisma.user.findUnique({ + where: { id: session.userId }, + select: { email: true }, + }); + + if (user?.email) + await sendLoginAlertEmail(user.email, { + ip: session.ipAddress || "Unknown", + device: session.userAgent || "Unknown", + timestamp: session.createdAt.toISOString(), + }); + } catch (error) { + console.error("Failed to send login alert email for session:", session.id, error); + } + }, + }, + delete: { after: async (session) => { try { diff --git a/apps/server/src/auth/mailer.ts b/apps/server/src/auth/mailer.ts index ba0018a3..525c47d0 100644 --- a/apps/server/src/auth/mailer.ts +++ b/apps/server/src/auth/mailer.ts @@ -2,6 +2,13 @@ import nodemailer from "nodemailer"; import { config, isDevelopment } from "#config"; +import { + type LoginAlertMeta, + renderOtpEmail, + renderWelcomeEmail, + renderLoginAlertEmail, +} from "#mail/index"; + import { logger } from "#utils/logger"; interface AuthOtpEmailPayload { @@ -28,112 +35,85 @@ function getOtpEmailSubject(type: AuthOtpEmailPayload["type"]) { } } -function getOtpEmailHeadline(type: AuthOtpEmailPayload["type"]) { - switch (type) { - case "email-verification": - return "Verify your email"; - case "forget-password": - return "Password reset code"; - case "change-email": - return "Confirm email change"; - case "sign-in": - default: - return "Use this code to sign in"; - } -} +async function sendMail({ + to, + subject, + text, + html, +}: { + to: string; + subject: string; + text: string; + html: string; +}) { + if (config.auth.emailProvider === "smtp") { + if (!validateSmtpConfig()) { + throw new Error("SMTP provider selected but SMTP environment values are incomplete"); + } -function buildOtpEmailHtml(payload: AuthOtpEmailPayload) { - const headline = getOtpEmailHeadline(payload.type); - - return ` - - - - - - ${headline} - - - - - - -
- - - - - - - - - - -
-
VeriWorkly
-

${headline}

-
-

Hi,

-

- Use the one-time verification code below to continue in VeriWorkly. -

-
-
${payload.otp}
-
-

- This code expires in a few minutes and can only be used once. -

-

- If you did not request this code, you can safely ignore this email. -

-
-
-

- VeriWorkly Security Team -

-
-
- -`; -} + const transporter = nodemailer.createTransport({ + host: config.auth.smtpHost, + port: config.auth.smtpPort, + secure: config.auth.smtpSecure, + auth: { + user: config.auth.smtpUser, + pass: config.auth.smtpPass, + }, + }); -async function sendViaSmtp(payload: AuthOtpEmailPayload) { - if (!validateSmtpConfig()) { - throw new Error("SMTP provider selected but SMTP environment values are incomplete"); + await transporter.sendMail({ + from: config.auth.emailFrom, + to, + subject, + text, + html, + }); + return; } - const transporter = nodemailer.createTransport({ - host: config.auth.smtpHost, - port: config.auth.smtpPort, - secure: config.auth.smtpSecure, - auth: { - user: config.auth.smtpUser, - pass: config.auth.smtpPass, - }, - }); + if (!isDevelopment) { + throw new Error("Console email provider is only available in development"); + } - await transporter.sendMail({ - from: config.auth.emailFrom, - to: payload.email, - subject: getOtpEmailSubject(payload.type), - text: `Your VeriWorkly verification code is: ${payload.otp}`, - html: buildOtpEmailHtml(payload), + logger.info("Email sent to console (dev mode)", { + to, + subject, + text, }); } +/** + * Send authentication OTP email using premium HTML template + */ export async function sendAuthOtpEmail(payload: AuthOtpEmailPayload): Promise { - if (config.auth.emailProvider === "smtp") { - await sendViaSmtp(payload); - return; - } + const subject = getOtpEmailSubject(payload.type); + const text = `Your VeriWorkly verification code is: ${payload.otp}`; + const html = renderOtpEmail(payload.otp, payload.type); - if (!isDevelopment) { - throw new Error("Console OTP provider is only available in development"); - } + await sendMail({ to: payload.email, subject, text, html }); +} - logger.info("OTP generated (dev mode)", { - email: payload.email, - otp: payload.otp, - type: payload.type, - }); +/** + * Send welcome email to a new user + */ +export async function sendWelcomeEmail(email: string, name: string): Promise { + const subject = "Welcome to VeriWorkly!"; + const text = `Welcome to VeriWorkly, ${name}! Start building your professional documents and portfolio today.`; + + // Resolve dashboard URL + const dashboardUrl = `${config.auth.baseUrl.replace(/\/api\/v1\/auth\/?$/, "")}/dashboard`; + const html = renderWelcomeEmail(name, dashboardUrl); + + await sendMail({ to: email, subject, text, html }); +} + +/** + * Send security login alert email + */ +export async function sendLoginAlertEmail(email: string, meta: LoginAlertMeta): Promise { + const subject = "Security Alert: New Sign-in Detected"; + const text = `A new login was detected on your VeriWorkly account. IP: ${meta.ip}, Device: ${meta.device}, Time: ${meta.timestamp}`; + const html = renderLoginAlertEmail(email, meta); + + await sendMail({ to: email, subject, text, html }); } diff --git a/apps/server/src/mail/index.ts b/apps/server/src/mail/index.ts new file mode 100644 index 00000000..8fc65ca0 --- /dev/null +++ b/apps/server/src/mail/index.ts @@ -0,0 +1,3 @@ +export { renderOtpEmail } from "./otp"; +export { renderWelcomeEmail } from "./welcome"; +export { renderLoginAlertEmail, type LoginAlertMeta } from "./loginAlert"; diff --git a/apps/server/src/mail/layout.ts b/apps/server/src/mail/layout.ts new file mode 100644 index 00000000..78d0b5ca --- /dev/null +++ b/apps/server/src/mail/layout.ts @@ -0,0 +1,111 @@ +export interface MailLayoutOptions { + title: string; + preheader: string; + bodyHtml: string; +} + +export function escapeHtml(unsafe: string): string { + return unsafe + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +export function getBaseLayout({ title, preheader, bodyHtml }: MailLayoutOptions): string { + return ` + + + + + + + + ${title} + + + + +
+ ${preheader} +
+ + + + + +
+ + + + + + + + + + + + + + + + + +
+ +`; +} diff --git a/apps/server/src/mail/loginAlert.ts b/apps/server/src/mail/loginAlert.ts new file mode 100644 index 00000000..edad874c --- /dev/null +++ b/apps/server/src/mail/loginAlert.ts @@ -0,0 +1,61 @@ +import { getBaseLayout, escapeHtml } from "./layout"; + +export interface LoginAlertMeta { + ip: string; + device: string; + timestamp: string; + location?: string; +} + +export function renderLoginAlertEmail(email: string, meta: LoginAlertMeta): string { + const sanitizedEmail = escapeHtml(email); + const title = "New Login Alert"; + const preheader = `A new login was detected on your VeriWorkly account for ${sanitizedEmail}.`; + + const bodyHtml = ` +

+ New Login Alert +

+

+ We detected a new sign-in to your VeriWorkly account (${sanitizedEmail}). Please review the session details below. +

+ + + + + + + + + + + + ${ + meta.location + ? ` + + + + + ` + : "" + } + + + + +
Device / Browser:${escapeHtml(meta.device)}
IP Address:${escapeHtml(meta.ip)}
Location:${escapeHtml(meta.location)}
Timestamp (UTC):${escapeHtml(meta.timestamp)}
+ + + + + + +
+ Was this not you? + If you did not authorize this login, your account security could be compromised. Please reply to this alert immediately or reach out to security@veriworkly.com. +
+ `; + + return getBaseLayout({ title, preheader, bodyHtml }); +} diff --git a/apps/server/src/mail/otp.ts b/apps/server/src/mail/otp.ts new file mode 100644 index 00000000..b4a3c364 --- /dev/null +++ b/apps/server/src/mail/otp.ts @@ -0,0 +1,50 @@ +import { getBaseLayout, escapeHtml } from "./layout"; + +export function renderOtpEmail( + otp: string, + type: "sign-in" | "email-verification" | "forget-password" | "change-email", +): string { + let title = "Sign in to VeriWorkly"; + let headline = "Verification Code"; + const preheader = `Your VeriWorkly verification code is ${otp}`; + let desc = "Confirm your request using the secure verification code below."; + + if (type === "email-verification") { + title = "Verify your email"; + headline = "Verify email address"; + desc = "Use this code to confirm your email address and activate your VeriWorkly profile."; + } + + const formattedOtp = otp.length === 6 ? `${otp.slice(0, 3)} ${otp.slice(3)}` : otp; + + const bodyHtml = ` +

+ ${headline} +

+

+ ${desc} +

+ + + + + + +
+
+
One-Time Code
+
${escapeHtml(formattedOtp)}
+
+
+ This code will expire in 10 minutes and can only be used once. If you didn't request this sign-in, you can safely ignore this email. +
+
+
+ +

+ VeriWorkly uses passwordless OTP authentication to guarantee maximum credential security. +

+ `; + + return getBaseLayout({ title, preheader, bodyHtml }); +} diff --git a/apps/server/src/mail/welcome.ts b/apps/server/src/mail/welcome.ts new file mode 100644 index 00000000..b02550fd --- /dev/null +++ b/apps/server/src/mail/welcome.ts @@ -0,0 +1,107 @@ +import { getBaseLayout, escapeHtml } from "./layout"; + +export function renderWelcomeEmail(name: string, dashboardUrl: string): string { + const sanitizedName = escapeHtml(name || "there"); + const title = "Welcome to VeriWorkly"; + const preheader = + "Welcome to VeriWorkly! Start building your professional documents and portfolio today."; + + const bodyHtml = ` +

+ Welcome to VeriWorkly, ${sanitizedName}! +

+

+ We're thrilled to have you. VeriWorkly is designed to help you construct pristine, ATS-optimized resumes and publish premium developer portfolios. +

+ + + + + + +
+ + + + + +
+
+
+
+
+
+
+
+ + + + +
+
+
+
+
+
+ + + + + + + + + + + + +
+ + + + + +
+ Privacy-First Editor + Control your profile, import GitHub data, and keep complete ownership of your details. +
+
+ + + + + +
+ Premium Layouts & Templates + Choose between editorial-style portfolios, clean resumes, and custom design schemes. +
+
+ + + + + +
+ Instant Subdomain Publishing + Go live instantly on a secure, optimized subpath or connect your custom domain name. +
+
+ + + + + + +
+ + Get Started & Create Document → + +
+ +

+ Need any assistance or have feedback? Drop us a line at support@veriworkly.com. We read every email. +

+ `; + + return getBaseLayout({ title, preheader, bodyHtml }); +} diff --git a/apps/server/src/services/atsAiService.ts b/apps/server/src/services/atsAiService.ts index 5cbb73bc..0cca1a4a 100644 --- a/apps/server/src/services/atsAiService.ts +++ b/apps/server/src/services/atsAiService.ts @@ -10,84 +10,123 @@ import { EntitlementService } from "#services/entitlementService"; import { ApiError } from "#utils/errors"; import { logger } from "#utils/logger"; +const nullableString = (maxLen: number) => + z + .string() + .max(maxLen) + .nullable() + .optional() + .transform((val) => val ?? ""); + +const nullableBoolean = z + .boolean() + .nullable() + .optional() + .transform((val) => val ?? false); + +const nullableStringArray = (maxItemLen: number, maxItems: number) => + z + .array( + z + .string() + .max(maxItemLen) + .nullable() + .optional() + .transform((val) => val ?? ""), + ) + .max(maxItems) + .nullable() + .optional() + .transform((val) => val ?? []); + const insightsSchema = z.object({ - explanation: z.string().min(1).max(4_000), - missingEvidence: z.array(z.string().min(1).max(500)).max(12), - keywordOpportunities: z.array(z.string().min(1).max(200)).max(20), - recommendedImprovements: z.array(z.string().min(1).max(500)).max(12), - priorityOrder: z.array(z.string().min(1).max(500)).max(12), + explanation: nullableString(4_000), + missingEvidence: nullableStringArray(500, 12), + keywordOpportunities: nullableStringArray(200, 20), + recommendedImprovements: nullableStringArray(500, 12), + priorityOrder: nullableStringArray(500, 12), }); const convertedResumeSchema = z.object({ basics: z.object({ - fullName: z.string().max(200).default(""), - role: z.string().max(200).default(""), - headline: z.string().max(500).default(""), - email: z.string().max(320).default(""), - phone: z.string().max(100).default(""), - location: z.string().max(300).default(""), + fullName: nullableString(200), + role: nullableString(200), + headline: nullableString(500), + email: nullableString(320), + phone: nullableString(100), + location: nullableString(300), }), links: z .array( z.object({ - label: z.string().max(100), - url: z.string().max(2_048), + label: nullableString(100), + url: nullableString(2_048), }), ) .max(20) - .default([]), - summary: z.string().max(4_000).default(""), + .nullable() + .optional() + .transform((val) => val ?? []), + summary: nullableString(4_000), experience: z .array( z.object({ - company: z.string().max(300).default(""), - role: z.string().max(300).default(""), - location: z.string().max(300).default(""), - startDate: z.string().max(20).default(""), - endDate: z.string().max(20).default(""), - current: z.boolean().default(false), - summary: z.string().max(2_000).default(""), - highlights: z.array(z.string().max(1_000)).max(20).default([]), + company: nullableString(300), + role: nullableString(300), + location: nullableString(300), + startDate: nullableString(20), + endDate: nullableString(20), + current: nullableBoolean, + summary: nullableString(2_000), + highlights: nullableStringArray(1_000, 20), }), ) .max(30) - .default([]), + .nullable() + .optional() + .transform((val) => val ?? []), education: z .array( z.object({ - school: z.string().max(300).default(""), - degree: z.string().max(300).default(""), - field: z.string().max(300).default(""), - startDate: z.string().max(20).default(""), - endDate: z.string().max(20).default(""), - current: z.boolean().default(false), - summary: z.string().max(2_000).default(""), + school: nullableString(300), + degree: nullableString(300), + field: nullableString(300), + startDate: nullableString(20), + endDate: nullableString(20), + current: nullableBoolean, + summary: nullableString(2_000), }), ) .max(20) - .default([]), + .nullable() + .optional() + .transform((val) => val ?? []), projects: z .array( z.object({ - name: z.string().max(300).default(""), - role: z.string().max(300).default(""), - link: z.string().max(2_048).default(""), - summary: z.string().max(2_000).default(""), - highlights: z.array(z.string().max(1_000)).max(20).default([]), - skills: z.array(z.string().max(100)).max(30).default([]), + name: nullableString(300), + role: nullableString(300), + link: nullableString(2_048), + summary: nullableString(2_000), + highlights: nullableStringArray(1_000, 20), + skills: nullableStringArray(100, 30), }), ) .max(30) - .default([]), + .nullable() + .optional() + .transform((val) => val ?? []), skills: z .array( z.object({ - name: z.string().max(200), - keywords: z.array(z.string().max(100)).max(50), + name: nullableString(200), + keywords: nullableStringArray(100, 50), }), ) .max(30) - .default([]), + .nullable() + .optional() + .transform((val) => val ?? []), }); function complexity(report: AtsReport, resumeChars: number, jobChars: number): AtsComplexity { diff --git a/apps/server/src/services/atsQuotaService.ts b/apps/server/src/services/atsQuotaService.ts index 2049967f..ecab0f71 100644 --- a/apps/server/src/services/atsQuotaService.ts +++ b/apps/server/src/services/atsQuotaService.ts @@ -53,15 +53,16 @@ export class AtsQuotaService { const key = paid?.key ?? `ats:quota:${tier}:${userId ?? anonymousId(ip)}`; const redis = getRedis(); const used = Number((await redis.get(key)) ?? 0); - const ttl = paid + const rawTtl = paid ? Math.max(1, Math.ceil((paid.end.getTime() - Date.now()) / 1000)) - : Math.max(1, await redis.ttl(key)); + : await redis.ttl(key); + const ttl = rawTtl > 0 ? rawTtl : windowSeconds; return { tier, limit, used, remaining: Math.max(0, limit - used), - resetsAt: new Date(Date.now() + (ttl > 0 ? ttl : windowSeconds) * 1000).toISOString(), + resetsAt: new Date(Date.now() + ttl * 1000).toISOString(), canConvertResume: Boolean(paid), pricing: publicAtsPolicy(), }; diff --git a/apps/server/tests/ats/ai-service.test.ts b/apps/server/tests/ats/ai-service.test.ts index 85b7f19b..142099b6 100644 --- a/apps/server/tests/ats/ai-service.test.ts +++ b/apps/server/tests/ats/ai-service.test.ts @@ -155,6 +155,64 @@ describe("ATS AI service", () => { expect(result.creditsSpent).toBe(25); }); + it("resiliently parses LLM conversion outputs containing nulls or missing fields", async () => { + completionCreate.mockResolvedValue({ + id: "conversion_resilient", + choices: [ + { + message: { + content: JSON.stringify({ + basics: { + fullName: "Jane Doe", + role: null, + headline: undefined, + email: "jane@example.com", + phone: null, + location: null, + }, + links: null, + summary: null, + experience: [ + { + company: "Corp", + role: null, + location: undefined, + startDate: "2020", + endDate: null, + current: null, + summary: null, + highlights: [null, "achieved sales target", undefined], + }, + ], + education: null, + projects: undefined, + skills: null, + }), + }, + }, + ], + }); + const { AtsAiService } = await import("../../src/services/atsAiService"); + + const result = await AtsAiService.convertResume("user_1", "request_resilient", "Old resume"); + + expect(result.resume.basics.fullName).toBe("Jane Doe"); + expect(result.resume.basics.role).toBe(""); + expect(result.resume.basics.headline).toBe(""); + expect(result.resume.basics.phone).toBe(""); + expect(result.resume.basics.location).toBe(""); + expect(result.resume.links).toEqual([]); + expect(result.resume.summary).toBe(""); + expect(result.resume.experience[0].role).toBe(""); + expect(result.resume.experience[0].location).toBe(""); + expect(result.resume.experience[0].endDate).toBe(""); + expect(result.resume.experience[0].current).toBe(false); + expect(result.resume.experience[0].highlights).toEqual(["", "achieved sales target", ""]); + expect(result.resume.education).toEqual([]); + expect(result.resume.projects).toEqual([]); + expect(result.resume.skills).toEqual([]); + }); + it("releases conversion credits when provider output is invalid", async () => { completionCreate.mockResolvedValue({ id: "conversion_2", diff --git a/apps/server/tests/auth/better-auth-config.test.ts b/apps/server/tests/auth/better-auth-config.test.ts new file mode 100644 index 00000000..22a58729 --- /dev/null +++ b/apps/server/tests/auth/better-auth-config.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it, vi } from "vitest"; + +const { mockGetRedis, mockSendWelcomeEmail, mockSendLoginAlertEmail, mockPrisma } = vi.hoisted( + () => { + return { + mockGetRedis: vi.fn(), + mockSendWelcomeEmail: vi.fn(), + mockSendLoginAlertEmail: vi.fn(), + mockPrisma: { + user: { + findUnique: vi.fn(), + }, + }, + }; + }, +); + +vi.mock("#utils/redis", () => ({ + getRedis: mockGetRedis, + cacheGet: vi.fn(), + cacheSet: vi.fn(), + cacheDel: vi.fn(), +})); + +vi.mock("#utils/prisma", () => ({ + prisma: mockPrisma, +})); + +vi.mock("#auth/mailer", () => ({ + sendAuthOtpEmail: vi.fn(), + sendWelcomeEmail: mockSendWelcomeEmail, + sendLoginAlertEmail: mockSendLoginAlertEmail, +})); + +import { auth } from "#auth/index"; + +describe("Better Auth configuration", () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const options = auth.options as any; + + it("has rate limiting enabled with correct settings and custom rules", () => { + expect(options.rateLimit).toBeDefined(); + expect(options.rateLimit?.enabled).toBe(true); + expect(options.rateLimit?.storage).toBe("secondary-storage"); + expect(options.rateLimit?.customRules).toEqual({ + "/email-otp/send-verification-otp": { + window: 60, + max: 3, + }, + "/email-otp/verify-otp": { + window: 60, + max: 5, + }, + }); + }); + + it("has useSecureCookies defined under advanced settings", () => { + expect(options.advanced).toBeDefined(); + // In test environment, nodeEnv is not production, so useSecureCookies should be false + expect(options.advanced?.useSecureCookies).toBe(false); + }); + + it("has storeSessionInDatabase enabled to keep DB sessions active", () => { + expect(options.session).toBeDefined(); + expect(options.session?.storeSessionInDatabase).toBe(true); + }); + + it("implements secondaryStorage adapter that interacts with Redis", async () => { + expect(options.secondaryStorage).toBeDefined(); + const storage = options.secondaryStorage!; + + const redisMockInstance = { + get: vi.fn().mockResolvedValue("cached-val"), + set: vi.fn().mockResolvedValue("OK"), + del: vi.fn().mockResolvedValue(1), + }; + mockGetRedis.mockReturnValue(redisMockInstance); + + // Test get + const getVal = await storage.get("test-key"); + expect(mockGetRedis).toHaveBeenCalled(); + expect(redisMockInstance.get).toHaveBeenCalledWith("test-key"); + expect(getVal).toBe("cached-val"); + + // Test set without TTL + await storage.set("test-key", "value"); + expect(redisMockInstance.set).toHaveBeenCalledWith("test-key", "value"); + + // Test set with TTL + await storage.set("test-key", "value", 120); + expect(redisMockInstance.set).toHaveBeenCalledWith("test-key", "value", { EX: 120 }); + + // Test delete + await storage.delete("test-key"); + expect(redisMockInstance.del).toHaveBeenCalledWith("test-key"); + }); + + it("sends a welcome email on user.create.after", async () => { + const userCreateAfter = options.databaseHooks?.user?.create?.after; + expect(userCreateAfter).toBeDefined(); + + const mockUser = { + email: "newuser@example.com", + name: "John Doe", + }; + + await userCreateAfter(mockUser); + expect(mockSendWelcomeEmail).toHaveBeenCalledWith("newuser@example.com", "John Doe"); + }); + + it("sends a login alert email on session.create.after", async () => { + const sessionCreateAfter = options.databaseHooks?.session?.create?.after; + expect(sessionCreateAfter).toBeDefined(); + + mockPrisma.user.findUnique.mockResolvedValue({ + email: "user@example.com", + }); + + const mockSession = { + userId: "user-123", + ipAddress: "192.168.1.1", + userAgent: "Mozilla/5.0", + createdAt: new Date("2026-06-25T12:00:00.000Z"), + id: "session-123", + }; + + await sessionCreateAfter(mockSession); + expect(mockPrisma.user.findUnique).toHaveBeenCalledWith({ + where: { id: "user-123" }, + select: { email: true }, + }); + expect(mockSendLoginAlertEmail).toHaveBeenCalledWith("user@example.com", { + ip: "192.168.1.1", + device: "Mozilla/5.0", + timestamp: mockSession.createdAt.toISOString(), + }); + }); +}); diff --git a/apps/server/tests/auth/mailer.test.ts b/apps/server/tests/auth/mailer.test.ts new file mode 100644 index 00000000..e17ff2a2 --- /dev/null +++ b/apps/server/tests/auth/mailer.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const { mockSendMail, mockCreateTransport } = vi.hoisted(() => { + const sendMail = vi.fn(); + return { + mockSendMail: sendMail, + mockCreateTransport: vi.fn().mockReturnValue({ + sendMail, + }), + }; +}); + +vi.mock("nodemailer", () => ({ + default: { + createTransport: mockCreateTransport, + }, +})); + +const { mockLogger } = vi.hoisted(() => { + return { + mockLogger: { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + }, + }; +}); + +vi.mock("#utils/logger", () => ({ + logger: mockLogger, +})); + +// Mock config values +const mockConfig = vi.hoisted(() => ({ + auth: { + emailProvider: "console", + emailFrom: "VeriWorkly ", + smtpHost: "smtp.example.com", + smtpPort: 587, + smtpSecure: false, + smtpUser: "user", + smtpPass: "pass", + baseUrl: "http://localhost:8080/api/v1/auth", + }, +})); + +vi.mock("#config", () => ({ + config: mockConfig, + isDevelopment: true, +})); + +import { sendAuthOtpEmail, sendWelcomeEmail, sendLoginAlertEmail } from "#auth/mailer"; + +describe("mailer services", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockConfig.auth.emailProvider = "console"; + }); + + describe("development console mode", () => { + it("logs OTP email to the console in development mode", async () => { + await sendAuthOtpEmail({ + email: "test@example.com", + otp: "123456", + type: "sign-in", + }); + + expect(mockLogger.info).toHaveBeenCalledWith( + expect.stringContaining("Email sent to console"), + expect.objectContaining({ + to: "test@example.com", + subject: "Your VeriWorkly sign-in code", + }), + ); + expect(mockCreateTransport).not.toHaveBeenCalled(); + }); + + it("logs Welcome email to the console in development mode", async () => { + await sendWelcomeEmail("welcome@example.com", "Jane Doe"); + + expect(mockLogger.info).toHaveBeenCalledWith( + expect.stringContaining("Email sent to console"), + expect.objectContaining({ + to: "welcome@example.com", + subject: "Welcome to VeriWorkly!", + }), + ); + }); + + it("logs Login Alert email to the console in development mode", async () => { + await sendLoginAlertEmail("alert@example.com", { + ip: "127.0.0.1", + device: "Chrome / Windows", + timestamp: "2026-06-26 12:00:00", + location: "San Francisco, CA", + }); + + expect(mockLogger.info).toHaveBeenCalledWith( + expect.stringContaining("Email sent to console"), + expect.objectContaining({ + to: "alert@example.com", + subject: "Security Alert: New Sign-in Detected", + }), + ); + }); + }); + + describe("production SMTP mode", () => { + beforeEach(() => { + mockConfig.auth.emailProvider = "smtp"; + }); + + it("sends OTP email via nodemailer when SMTP is configured", async () => { + await sendAuthOtpEmail({ + email: "smtp-test@example.com", + otp: "987654", + type: "email-verification", + }); + + expect(mockCreateTransport).toHaveBeenCalled(); + expect(mockSendMail).toHaveBeenCalledWith( + expect.objectContaining({ + from: "VeriWorkly ", + to: "smtp-test@example.com", + subject: "Verify your VeriWorkly email", + text: expect.stringContaining("987654"), + html: expect.stringContaining("987654"), + }), + ); + }); + + it("throws error if SMTP environment configuration is missing", async () => { + mockConfig.auth.smtpHost = ""; // invalid config + + await expect( + sendAuthOtpEmail({ + email: "smtp-test@example.com", + otp: "987654", + type: "email-verification", + }), + ).rejects.toThrow("SMTP provider selected but SMTP environment values are incomplete"); + + mockConfig.auth.smtpHost = "smtp.example.com"; // restore config + }); + }); +}); diff --git a/apps/server/tests/auth/rate-limit-middleware.test.ts b/apps/server/tests/auth/rate-limit-middleware.test.ts new file mode 100644 index 00000000..fd7488d6 --- /dev/null +++ b/apps/server/tests/auth/rate-limit-middleware.test.ts @@ -0,0 +1,117 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Request, Response, NextFunction } from "express"; + +const { mockGetRedis } = vi.hoisted(() => { + return { + mockGetRedis: vi.fn(), + }; +}); + +vi.mock("#config", () => ({ + config: { + nodeEnv: "production", + rateLimit: { + windowMs: 60000, + maxRequests: 5, + authWindowMs: 60000, + authMaxRequests: 2, + }, + ai: { + rateLimitWindowMs: 60000, + rateLimitMaxRequests: 3, + }, + }, +})); + +vi.mock("#utils/redis", () => ({ + getRedis: mockGetRedis, +})); + +vi.mock("#utils/logger", () => ({ + logger: { + warn: vi.fn(), + error: vi.fn(), + }, +})); + +import { rateLimitMiddleware } from "#middleware/rateLimit"; + +describe("rateLimitMiddleware", () => { + let req: Partial & { headers: Record }; + let res: Partial; + let next: NextFunction; + beforeEach(() => { + mockGetRedis.mockReset(); + req = { + headers: {}, + ip: "192.168.1.100", + path: "/api/v1/auth/sign-in", + method: "POST", + socket: { + remoteAddress: "192.168.1.100", + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + res = { + set: vi.fn(), + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + }; + next = vi.fn(); + }); + it("passes requests under the limit using Redis", async () => { + const redisMockInstance = { + isOpen: true, + eval: vi.fn().mockResolvedValue([1, 60000]), // count: 1, ttl: 60000 + }; + mockGetRedis.mockReturnValue(redisMockInstance); + + await rateLimitMiddleware(req as Request, res as Response, next); + + // Wait briefly for promises in middleware to resolve + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + + it("returns 429 when request count exceeds the limit using Redis", async () => { + const redisMockInstance = { + isOpen: true, + eval: vi.fn().mockResolvedValue([3, 50000]), // count: 3, limit: 2 (authMaxRequests) + }; + mockGetRedis.mockReturnValue(redisMockInstance); + + await rateLimitMiddleware(req as Request, res as Response, next); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(429); + expect(res.set).toHaveBeenCalledWith("Retry-After", "50"); + }); + + it("falls back to in-memory storage when Redis fails", async () => { + mockGetRedis.mockImplementation(() => { + throw new Error("Redis connection lost"); + }); + + // Request 1: should pass + await rateLimitMiddleware(req as Request, res as Response, next); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(next).toHaveBeenCalled(); + + next = vi.fn(); + // Request 2: should pass (limit is 2) + await rateLimitMiddleware(req as Request, res as Response, next); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(next).toHaveBeenCalled(); + + next = vi.fn(); + // Request 3: should fail with 429 + await rateLimitMiddleware(req as Request, res as Response, next); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(429); + }); +}); diff --git a/scripts/mock-template-library.mjs b/scripts/mock-template-library.mjs index bd84b334..62264f78 100644 --- a/scripts/mock-template-library.mjs +++ b/scripts/mock-template-library.mjs @@ -99,11 +99,59 @@ fs.writeFileSync(registryFilePath, registryContent); // AtelierTemplate const atelierContent = `import React from "react"; -import type { PortfolioProject } from "../types"; +import { safeExternalUrl, itemText, type PortfolioProject } from "../types"; import "./styles.css"; export default function AtelierTemplate({ project }: { project: PortfolioProject }) { - return React.createElement("div", null, "Mock Atelier Template: " + project.identity.name); + const visibleSections = project.sections.filter((s) => s.visible); + return ( +
+
Mock Atelier Template: {project.identity.name}
+ {visibleSections.map((section) => { + if (section.type === "projects") { + return ( +
+

{section.title}

+ {section.items.map((item, idx) => ( +
+

{itemText(item, "title") || itemText(item, "name")}

+

{itemText(item, "summary")}

+
+ ))} +
+ ); + } + if (section.type === "contact") { + return ( +
+

{section.title}

+ {project.socialLinks.map((link) => { + const href = safeExternalUrl(link.url); + return href && link.label.trim() ? ( + + {link.label} + + ) : null; + })} +
+ ); + } + return ( +
+

{section.title}

+ {section.type === "services" &&
} + {section.type === "testimonials" &&
} + {section.items.map((item, idx) => ( +
+

{itemText(item, "title") || itemText(item, "name")}

+

{itemText(item, "summary")}

+
+ ))} +
+ ); + })} +
+ ); } `; @@ -112,11 +160,59 @@ fs.writeFileSync(path.join(templateLibPath, "atelier", "styles.css"), "/* Mock s // SignalTemplate const signalContent = `import React from "react"; -import type { PortfolioProject } from "../types"; +import { safeExternalUrl, itemText, type PortfolioProject } from "../types"; import "./styles.css"; export default function SignalTemplate({ project }: { project: PortfolioProject }) { - return React.createElement("div", null, "Mock Signal Template: " + project.identity.name); + const visibleSections = project.sections.filter((s) => s.visible); + return ( +
+
Mock Signal Template: {project.identity.name}
+ {visibleSections.map((section) => { + if (section.type === "projects") { + return ( +
+

{section.title}

+ {section.items.map((item, idx) => ( +
+

{itemText(item, "title") || itemText(item, "name")}

+

{itemText(item, "summary")}

+
+ ))} +
+ ); + } + if (section.type === "contact") { + return ( +
+

{section.title}

+ {project.socialLinks.map((link) => { + const href = safeExternalUrl(link.url); + return href && link.label.trim() ? ( + + {link.label} + + ) : null; + })} +
+ ); + } + return ( +
+

{section.title}

+ {section.type === "experience" &&
} + {section.type === "testimonials" &&
} + {section.items.map((item, idx) => ( +
+

{itemText(item, "title") || itemText(item, "name")}

+

{itemText(item, "summary")}

+
+ ))} +
+ ); + })} +
+ ); } `;