summaryrefslogtreecommitdiff
path: root/test/signin-routes.test.ts
diff options
context:
space:
mode:
Diffstat (limited to 'test/signin-routes.test.ts')
-rw-r--r--test/signin-routes.test.ts132
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,
+ );
+ });
+});