summaryrefslogtreecommitdiff
path: root/test/signin-routes.test.ts
blob: 98d11530d034b209df30c02dc22eb29ac01c5a16 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
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,
    );
  });
});