diff options
Diffstat (limited to 'test/signin-routes.test.ts')
| -rw-r--r-- | test/signin-routes.test.ts | 132 |
1 files changed, 132 insertions, 0 deletions
diff --git a/test/signin-routes.test.ts b/test/signin-routes.test.ts new file mode 100644 index 0000000..98d1153 --- /dev/null +++ b/test/signin-routes.test.ts @@ -0,0 +1,132 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import argon2 from "argon2"; +import * as jose from "jose"; +import { db } from "../src/db/db"; +import { users } from "../src/db/schema"; +import { eq } from "drizzle-orm"; +import signinRouter from "../src/routes/signin"; +import type { Request, Response } from "express"; + +const sendMail = vi.fn(); + +vi.mock("../src/email/email", () => ({ + getTransporter: () => ({ + sendMail, + }), +})); + +describe("Signin Routes", () => { + beforeEach(() => { + process.env.RESET_PASSWORD_JWT_SECRET = "test-reset-secret"; + process.env.SMTP_EMAIL = "buddy@example.com"; + sendMail.mockReset(); + }); + + function getRouteHandler(path: string) { + const layer = ( + signinRouter as unknown as { + stack: Array<{ + route?: { + path: string; + stack: Array<{ handle: (req: Request, res: Response) => unknown }>; + }; + }>; + } + ).stack.find((candidate) => candidate.route?.path === path); + + if (!layer?.route?.stack[0]) { + throw new Error(`Route handler for ${path} not found`); + } + + return layer.route.stack[0].handle; + } + + async function invokeRoute(body: unknown) { + const handler = getRouteHandler("/resetpassword"); + const req = { body } as Request; + const res = { + statusCode: 200, + body: undefined as unknown, + status(code: number) { + this.statusCode = code; + return this; + }, + send(payload: unknown) { + this.body = payload; + return this; + }, + } as Response & { statusCode: number; body: unknown }; + + await handler(req, res); + + return res; + } + + test("should send a password reset email with a signed reset link", async () => { + const response = await invokeRoute({ + email: "test@example.com", + link: "https://buddy.example/reset-password", + }); + + expect(response.statusCode).toBe(200); + expect(response.body).toEqual({ + success: true, + reason: "", + }); + expect(sendMail).toHaveBeenCalledTimes(1); + + const mail = sendMail.mock.calls[0]?.[0]; + expect(mail.subject).toBe("Buddy password reset"); + expect(mail.text).toContain("Your Buddy password reset link is "); + + const sentLink = mail.text.replace( + "Your Buddy password reset link is ", + "", + ); + const token = new URL(sentLink).searchParams.get("token"); + + expect(token).toBeTruthy(); + + const { payload } = await jose.jwtVerify( + token!, + new TextEncoder().encode(process.env.RESET_PASSWORD_JWT_SECRET), + { + issuer: "urn:lajosh:buddy", + audience: "urn:buddy:password-reset", + }, + ); + + expect(payload.id).toBe(1); + expect(payload.type).toBe("password_reset"); + }); + + test("should update the user password when given a valid reset token", async () => { + const token = await new jose.SignJWT({ id: 1, type: "password_reset" }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .setIssuer("urn:lajosh:buddy") + .setAudience("urn:buddy:password-reset") + .setExpirationTime("1h") + .sign(new TextEncoder().encode(process.env.RESET_PASSWORD_JWT_SECRET!)); + + const response = await invokeRoute({ + token, + password: "new-password", + }); + + expect(response.statusCode).toBe(200); + expect(response.body).toEqual({ + success: true, + reason: "", + }); + + const updatedUser = ( + await db.select().from(users).where(eq(users.id, 1)).limit(1) + )[0]; + + expect(updatedUser).toBeTruthy(); + expect(await argon2.verify(updatedUser!.password, "new-password")).toBe( + true, + ); + }); +}); |